Add support for persistent storage of panel layouts, sizes, and active tabs (#4017)

* Add persistence to panel layouts

* Fix and persist the Window > Focus Document mode

* Add a Window > Reset Workspace action

* workspace_layout.json -> workspace_layout.ron

* Fix native app hole punch

* Cleanup review pass
This commit is contained in:
Keavon Chambers
2026-04-08 21:05:58 -07:00
committed by GitHub
parent b099e2faca
commit b100892bfa
20 changed files with 346 additions and 107 deletions

View File

@@ -51,39 +51,25 @@
height: 100%;
overflow: auto;
touch-action: none;
}
.workspace {
position: relative;
}
.release-candidate-expiry {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--color-e-nearwhite);
color: var(--color-2-mildblack);
opacity: 0.9;
pointer-events: none;
padding: 12px 40px;
border-radius: 4px;
text-align-last: justify;
font-size: 18px;
z-index: 1000;
// Needed for the viewport hole punch on desktop
.viewport-hole-punch .workspace .workspace-grid-subdivision:has(.panel.document-panel)::after {
content: "";
position: absolute;
inset: 6px;
border-radius: 6px;
box-shadow: 0 0 0 calc(100vw + 100vh) var(--color-2-mildblack);
z-index: -1;
}
.release-candidate-expiry {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--color-e-nearwhite);
color: var(--color-2-mildblack);
opacity: 0.9;
pointer-events: none;
padding: 12px 40px;
border-radius: 4px;
text-align-last: justify;
font-size: 18px;
z-index: 1000;
.text-label {
line-height: 1.5;
}
.text-label {
line-height: 1.5;
}
}
</style>

View File

@@ -12,8 +12,9 @@
const editor = getContext<EditorWrapper>("editor");
const portfolio = getContext<PortfolioStore>("portfolio");
export let subdivision: PanelLayoutSubdivision;
export let subdivision: PanelLayoutSubdivision | undefined;
export let depth: number;
export let splitPath: number[] = [];
// Local size overrides for gutter resizing (keyed by child index)
let sizeOverrides: Record<number, number> = {};
@@ -29,7 +30,7 @@
// Reset overrides when the subdivision changes (e.g., backend sends a new layout)
$: if (subdivision) sizeOverrides = {};
// Reactive array of resolved sizes (merging backend defaults with local overrides)
$: resolvedSizes = "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
$: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
$: documentTabLabels = $portfolio.documents.map((doc: OpenDocument) => {
const name = doc.details.name;
const unsaved = !doc.details.isSaved;
@@ -44,7 +45,7 @@
});
function resizePanel(e: PointerEvent, prevIndex: number, nextIndex: number) {
if (!("Split" in subdivision)) return;
if (!(subdivision && "Split" in subdivision)) return;
const gutter = e.target;
if (!(gutter instanceof HTMLDivElement)) return;
@@ -55,13 +56,13 @@
if (!(nextSibling instanceof HTMLDivElement) || !(prevSibling instanceof HTMLDivElement) || !(parentElement instanceof HTMLDivElement)) return;
// Double-click resets both adjacent panels to their default sizes
const children = subdivision.Split.children;
const now = Date.now();
const isDoubleClick = now - lastGutterClickTime < DOUBLE_CLICK_MILLISECONDS && lastGutterClickTarget === gutter;
lastGutterClickTime = now;
lastGutterClickTarget = gutter;
if (isDoubleClick) {
sizeOverrides = { ...sizeOverrides, [prevIndex]: children[prevIndex].size, [nextIndex]: children[nextIndex].size };
sizeOverrides = {};
editor.resetPanelGroupSizes(splitPath);
return;
}
@@ -113,6 +114,12 @@
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
removeListeners();
activeResizeCleanup = undefined;
// Persist the resized sizes to the backend
if ("Split" in subdivision) {
const allSizes = subdivision.Split.children.map((child, i) => sizeOverrides[i] ?? child.size);
editor.setPanelGroupSizes(splitPath, allSizes);
}
};
const onMouseDown = (e: MouseEvent) => {
@@ -159,7 +166,7 @@
}
</script>
{#if "PanelGroup" in subdivision}
{#if subdivision && "PanelGroup" in subdivision}
{@const group = subdivision.PanelGroup}
{#if isDocumentGroup(group.state)}
<Panel
@@ -182,7 +189,7 @@
panelId={String(group.id)}
panelTypes={group.state.tabs}
tabLabels={group.state.tabs.map((name) => ({ name }))}
tabActiveIndex={Number(group.state.activeTabIndex)}
tabActiveIndex={Number(group.state.active_tab_index)}
clickAction={(tabIndex) => editor.setPanelGroupActiveTab(group.id, tabIndex)}
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab(group.id, oldIndex, newIndex)}
crossPanelDropAction={crossPanelDrop}
@@ -190,7 +197,7 @@
splitDropAction={splitDrop}
/>
{/if}
{:else if "Split" in subdivision}
{:else if subdivision && "Split" in subdivision}
{#each subdivision.Split.children as child, index}
{#if index > 0}
{#if horizontal}
@@ -201,28 +208,17 @@
{/if}
{#if horizontal}
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": resolvedSizes[index] }}>
<svelte:self subdivision={child.subdivision} depth={depth + 1} />
<svelte:self subdivision={child.subdivision} depth={depth + 1} splitPath={[...splitPath, index]} />
</LayoutCol>
{:else}
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": resolvedSizes[index] }}>
<svelte:self subdivision={child.subdivision} depth={depth + 1} />
<svelte:self subdivision={child.subdivision} depth={depth + 1} splitPath={[...splitPath, index]} />
</LayoutRow>
{/if}
{/each}
{/if}
<style lang="scss">
.workspace-grid-subdivision {
position: relative;
flex: 1 1 0;
min-height: 28px;
&.folded {
flex-grow: 0;
height: 0;
}
}
.workspace-grid-resize-gutter {
flex: 0 0 4px;
border-radius: 2px;
@@ -241,4 +237,25 @@
transition: background 0.2s 0.1s;
}
}
.workspace-grid-subdivision {
position: relative;
flex: 1 1 0;
min-height: 28px;
&.folded {
flex-grow: 0;
height: 0;
}
}
// Needed for the viewport hole punch on desktop
.viewport-hole-punch .workspace-grid-subdivision:has(> .panel.document-panel)::after {
content: "";
position: absolute;
z-index: -1;
inset: 6px;
border-radius: 6px;
box-shadow: 0 0 0 calc(100vw + 100vh) var(--color-2-mildblack);
}
</style>

View File

@@ -1,6 +1,16 @@
import type { PortfolioStore } from "/src/stores/portfolio";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { saveEditorPreferences, loadEditorPreferences, storeDocument, removeDocument, loadFirstDocument, loadRestDocuments, saveActiveDocument } from "/src/utility-functions/persistence";
import {
saveEditorPreferences,
loadEditorPreferences,
saveWorkspaceLayout,
loadWorkspaceLayout,
storeDocument,
removeDocument,
loadFirstDocument,
loadRestDocuments,
saveActiveDocument,
} from "/src/utility-functions/persistence";
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
@@ -22,6 +32,14 @@ export function createPersistenceManager(subscriptions: SubscriptionsRouter, edi
await loadEditorPreferences(editor);
});
subscriptions.subscribeFrontendMessage("TriggerSaveWorkspaceLayout", async (data) => {
await saveWorkspaceLayout(data.workspaceLayout);
});
subscriptions.subscribeFrontendMessage("TriggerLoadWorkspaceLayout", async () => {
await loadWorkspaceLayout(editor);
});
subscriptions.subscribeFrontendMessage("TriggerPersistenceWriteDocument", async (data) => {
await storeDocument(data, portfolio);
});
@@ -53,6 +71,8 @@ export function destroyPersistenceManager() {
subscriptions.unsubscribeFrontendMessage("TriggerSavePreferences");
subscriptions.unsubscribeFrontendMessage("TriggerLoadPreferences");
subscriptions.unsubscribeFrontendMessage("TriggerSaveWorkspaceLayout");
subscriptions.unsubscribeFrontendMessage("TriggerLoadWorkspaceLayout");
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteDocument");
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceRemoveDocument");
subscriptions.unsubscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument");

View File

@@ -18,7 +18,7 @@ const initialState: PortfolioStoreState = {
unsaved: false,
documents: [],
activeDocumentIndex: 0,
panelLayout: { root: { Split: { children: [] } }, nextGroupId: 0n },
panelLayout: {},
};
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;

View File

@@ -161,6 +161,15 @@ export async function loadEditorPreferences(editor: EditorWrapper) {
editor.loadPreferences(preferences ? JSON.stringify(preferences) : undefined);
}
export async function saveWorkspaceLayout(layout: unknown) {
await databaseSet("workspace_layout", layout);
}
export async function loadWorkspaceLayout(editor: EditorWrapper) {
const layout = await databaseGet<Record<string, unknown>>("workspace_layout");
if (layout) editor.loadWorkspaceLayout(layout);
}
export async function wipeDocuments() {
await databaseDelete("documents_tab_order");
await databaseDelete("current_document_id");

View File

@@ -379,6 +379,16 @@ impl EditorWrapper {
}
}
#[wasm_bindgen(js_name = loadWorkspaceLayout)]
pub fn load_workspace_layout(&self, layout: JsValue) {
let Ok(layout) = serde_wasm_bindgen::from_value(layout) else {
log::error!("Failed to deserialize workspace layout");
return;
};
let message = PortfolioMessage::LoadWorkspaceLayout { layout };
self.dispatch(message);
}
#[wasm_bindgen(js_name = selectDocument)]
pub fn select_document(&self, document_id: u64) {
let document_id = DocumentId(document_id);
@@ -486,6 +496,21 @@ impl EditorWrapper {
self.dispatch(message);
}
#[wasm_bindgen(js_name = resetPanelGroupSizes)]
pub fn reset_panel_group_sizes(&self, split_path: JsValue) {
let split_path: Vec<usize> = serde_wasm_bindgen::from_value(split_path).unwrap();
let message = PortfolioMessage::ResetPanelGroupSizes { split_path };
self.dispatch(message);
}
#[wasm_bindgen(js_name = setPanelGroupSizes)]
pub fn set_panel_group_sizes(&self, split_path: JsValue, sizes: JsValue) {
let split_path: Vec<usize> = serde_wasm_bindgen::from_value(split_path).unwrap();
let sizes: Vec<f64> = serde_wasm_bindgen::from_value(sizes).unwrap();
let message = PortfolioMessage::SetPanelGroupSizes { split_path, sizes };
self.dispatch(message);
}
#[wasm_bindgen(js_name = closeDocumentWithConfirmation)]
pub fn close_document_with_confirmation(&self, document_id: u64) {
let document_id = DocumentId(document_id);