Skip to content

Commit 8371d7f

Browse files
committed
feat(web): preserve editor state in popout tabs
1 parent 1e40b14 commit 8371d7f

11 files changed

+1253
-941
lines changed

runtime/test/web/app-branch-pane-orchestration.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ test('resolvePanePopoutTransfer activates an inactive tab before requesting tran
8080
expect(activateCalls).toEqual(['piclaw://vnc/lab']);
8181
});
8282

83+
test('resolvePanePopoutTransfer falls back to generic editor transfer payloads', async () => {
84+
const buildEditorPopoutTransfer = (panePath: string) => ({ editor_popout: `token:${panePath}` });
85+
86+
await expect(resolvePanePopoutTransfer({
87+
panePath: '/workspace/notes.md',
88+
tabStripActiveId: '/workspace/notes.md',
89+
editorInstanceRef: { current: { getContent: () => '# Draft', isDirty: () => true } },
90+
dockInstanceRef: { current: null },
91+
terminalTabPath: '/__terminal__',
92+
buildEditorPopoutTransfer,
93+
})).resolves.toEqual({ editor_popout: 'token:/workspace/notes.md' });
94+
});
95+
8396
test('closeTransferredPaneSource closes clean tabs and falls back to hiding dock for terminal', () => {
8497
const closed: string[] = [];
8598
const dockVisibility: boolean[] = [];
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { expect, test } from 'bun:test';
2+
3+
import {
4+
consumeEditorPopoutState,
5+
consumePanePopoutTransferToken,
6+
createEditorPopoutTransferPayload,
7+
} from '../../web/src/panes/editor-popout-transfer.js';
8+
9+
function createStorage() {
10+
const map = new Map<string, string>();
11+
return {
12+
getItem: (key: string) => map.get(key) ?? null,
13+
setItem: (key: string, value: string) => {
14+
map.set(key, value);
15+
},
16+
removeItem: (key: string) => {
17+
map.delete(key);
18+
},
19+
size: () => map.size,
20+
};
21+
}
22+
23+
test('createEditorPopoutTransferPayload stores transferable editor state and consumeEditorPopoutState reads it once', () => {
24+
const localStorage = createStorage();
25+
const runtime = { localStorage } as any;
26+
27+
const payload = createEditorPopoutTransferPayload({
28+
path: '/workspace/notes.md',
29+
content: '# Draft',
30+
paneOverrideId: 'editor',
31+
viewState: { cursorLine: 3, cursorCol: 5, scrollTop: 42 },
32+
}, runtime, 1_000);
33+
34+
expect(payload).toEqual({ editor_popout: expect.any(String) });
35+
expect(localStorage.size()).toBe(1);
36+
37+
const restored = consumeEditorPopoutState(payload?.editor_popout, runtime, 1_500);
38+
expect(restored).toEqual({
39+
path: '/workspace/notes.md',
40+
content: '# Draft',
41+
mtime: undefined,
42+
paneOverrideId: 'editor',
43+
viewState: { cursorLine: 3, cursorCol: 5, scrollTop: 42 },
44+
capturedAt: 1_000,
45+
});
46+
expect(localStorage.size()).toBe(0);
47+
expect(consumeEditorPopoutState(payload?.editor_popout, runtime, 1_500)).toBeNull();
48+
});
49+
50+
test('consumeEditorPopoutState expires stale transfer payloads', () => {
51+
const localStorage = createStorage();
52+
const runtime = { localStorage } as any;
53+
54+
const payload = createEditorPopoutTransferPayload({
55+
path: '/workspace/notes.md',
56+
paneOverrideId: 'editor',
57+
}, runtime, 1_000);
58+
59+
expect(consumeEditorPopoutState(payload?.editor_popout, runtime, 1_000 + (5 * 60 * 1000) + 1)).toBeNull();
60+
});
61+
62+
test('consumePanePopoutTransferToken removes the query parameter after reading it', () => {
63+
const calls: string[] = [];
64+
const runtime = {
65+
window: {
66+
location: { href: 'https://example.test/?chat_jid=web%3Adefault&editor_popout=tok-123' },
67+
history: {
68+
state: { from: 'test' },
69+
replaceState: (_state: unknown, _title: string, url: string) => calls.push(url),
70+
},
71+
document: { title: 'PiClaw' },
72+
},
73+
} as any;
74+
75+
expect(consumePanePopoutTransferToken('editor_popout', runtime)).toBe('tok-123');
76+
expect(calls).toHaveLength(1);
77+
expect(calls[0]).not.toContain('editor_popout=');
78+
expect(calls[0]).toContain('chat_jid=web%3Adefault');
79+
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { TabViewState } from './tab-store.js';
2+
3+
const EDITOR_POPOUT_STATE_PREFIX = 'piclaw:editor-popout:';
4+
const EDITOR_POPOUT_STATE_TTL_MS = 5 * 60 * 1000;
5+
6+
export interface EditorPopoutTransferState {
7+
path: string;
8+
content?: string;
9+
mtime?: string | null;
10+
paneOverrideId?: string | null;
11+
viewState?: TabViewState | null;
12+
capturedAt?: number;
13+
}
14+
15+
function getStorage(runtime: any): Storage | null {
16+
try {
17+
return runtime?.localStorage ?? null;
18+
} catch {
19+
return null;
20+
}
21+
}
22+
23+
function createToken(nowMs = Date.now()): string {
24+
return `editor-popout-${nowMs.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
25+
}
26+
27+
function normalizePath(value: unknown): string {
28+
return typeof value === 'string' ? value.trim() : '';
29+
}
30+
31+
function normalizeOverrideId(value: unknown): string | null {
32+
const normalized = typeof value === 'string' ? value.trim() : '';
33+
return normalized || null;
34+
}
35+
36+
function normalizeContent(value: unknown): string | undefined {
37+
return typeof value === 'string' ? value : undefined;
38+
}
39+
40+
function normalizeMtime(value: unknown): string | null | undefined {
41+
if (value === undefined) return undefined;
42+
if (typeof value !== 'string') return null;
43+
const normalized = value.trim();
44+
return normalized || null;
45+
}
46+
47+
function normalizeViewState(value: unknown): TabViewState | null {
48+
if (!value || typeof value !== 'object') return null;
49+
const raw = value as Record<string, unknown>;
50+
const next: TabViewState = {};
51+
if (typeof raw.cursorLine === 'number' && Number.isFinite(raw.cursorLine)) next.cursorLine = raw.cursorLine;
52+
if (typeof raw.cursorCol === 'number' && Number.isFinite(raw.cursorCol)) next.cursorCol = raw.cursorCol;
53+
if (typeof raw.scrollTop === 'number' && Number.isFinite(raw.scrollTop)) next.scrollTop = raw.scrollTop;
54+
return Object.keys(next).length > 0 ? next : null;
55+
}
56+
57+
export function consumePanePopoutTransferToken(paramName: string, runtime: any = globalThis): string | null {
58+
const win = runtime?.window ?? runtime;
59+
if (!win?.location?.href) return null;
60+
try {
61+
const url = new URL(win.location.href);
62+
const token = url.searchParams.get(paramName)?.trim() || '';
63+
if (!token) return null;
64+
url.searchParams.delete(paramName);
65+
win.history?.replaceState?.(win.history.state, win.document?.title || '', url.toString());
66+
return token;
67+
} catch {
68+
return null;
69+
}
70+
}
71+
72+
export function stashEditorPopoutState(state: EditorPopoutTransferState, runtime: any = globalThis, nowMs = Date.now()): string | null {
73+
const storage = getStorage(runtime);
74+
const path = normalizePath(state?.path);
75+
if (!storage || !path) return null;
76+
77+
const payload: EditorPopoutTransferState = {
78+
path,
79+
content: normalizeContent(state?.content),
80+
mtime: normalizeMtime(state?.mtime),
81+
paneOverrideId: normalizeOverrideId(state?.paneOverrideId),
82+
viewState: normalizeViewState(state?.viewState),
83+
capturedAt: nowMs,
84+
};
85+
86+
const hasTransferData = Boolean(
87+
payload.content !== undefined
88+
|| payload.paneOverrideId
89+
|| payload.viewState
90+
|| payload.mtime,
91+
);
92+
if (!hasTransferData) return null;
93+
94+
const token = createToken(nowMs);
95+
try {
96+
storage.setItem(`${EDITOR_POPOUT_STATE_PREFIX}${token}`, JSON.stringify(payload));
97+
return token;
98+
} catch {
99+
return null;
100+
}
101+
}
102+
103+
export function consumeEditorPopoutState(token?: string | null, runtime: any = globalThis, nowMs = Date.now()): EditorPopoutTransferState | null {
104+
const normalizedToken = typeof token === 'string' ? token.trim() : '';
105+
const storage = getStorage(runtime);
106+
if (!normalizedToken || !storage) return null;
107+
108+
const key = `${EDITOR_POPOUT_STATE_PREFIX}${normalizedToken}`;
109+
let raw = '';
110+
try {
111+
raw = storage.getItem(key) || '';
112+
} catch {
113+
return null;
114+
}
115+
if (!raw) return null;
116+
117+
try {
118+
storage.removeItem(key);
119+
} catch {
120+
/* expected: one-shot transfer cleanup is best-effort. */
121+
}
122+
123+
try {
124+
const parsed = JSON.parse(raw) as Record<string, unknown>;
125+
const capturedAt = typeof parsed?.capturedAt === 'number' && Number.isFinite(parsed.capturedAt)
126+
? parsed.capturedAt
127+
: nowMs;
128+
if (capturedAt + EDITOR_POPOUT_STATE_TTL_MS < nowMs) {
129+
return null;
130+
}
131+
132+
const path = normalizePath(parsed?.path);
133+
if (!path) return null;
134+
135+
return {
136+
path,
137+
content: normalizeContent(parsed?.content),
138+
mtime: normalizeMtime(parsed?.mtime),
139+
paneOverrideId: normalizeOverrideId(parsed?.paneOverrideId),
140+
viewState: normalizeViewState(parsed?.viewState),
141+
capturedAt,
142+
};
143+
} catch {
144+
return null;
145+
}
146+
}
147+
148+
export function createEditorPopoutTransferPayload(state: EditorPopoutTransferState, runtime: any = globalThis, nowMs = Date.now()): Record<string, string> | null {
149+
const token = stashEditorPopoutState(state, runtime, nowMs);
150+
return token ? { editor_popout: token } : null;
151+
}

runtime/web/src/ui/app-branch-pane-lifecycle-actions.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect } from '../vendor/preact-htm.js';
2+
import { createEditorPopoutTransferPayload } from '../panes/editor-popout-transfer.js';
23
import { tabStore } from '../panes/index.js';
34
import { watchPaneOpenEvents } from './app-browser-events.js';
45
import {
@@ -29,6 +30,8 @@ interface RefBox<T> {
2930

3031
interface PaneTransferInstanceLike {
3132
preparePopoutTransfer?: () => Promise<Record<string, string> | null> | Record<string, string> | null;
33+
getContent?: () => string | undefined;
34+
isDirty?: () => boolean;
3235
}
3336

3437
interface BranchRecordLike {
@@ -272,6 +275,7 @@ export interface PopOutPaneActionOptions {
272275
editorInstanceRef: RefBox<PaneTransferInstanceLike | null>;
273276
dockInstanceRef: RefBox<PaneTransferInstanceLike | null>;
274277
terminalTabPath: string;
278+
tabPaneOverrides?: Map<string, string> | null;
275279
dockVisible: boolean;
276280
resolveTab: (path: string) => { dirty?: boolean } | null | undefined;
277281
closeTab: (path: string) => void;
@@ -292,6 +296,7 @@ export async function popOutPaneAction(options: PopOutPaneActionOptions): Promis
292296
editorInstanceRef,
293297
dockInstanceRef,
294298
terminalTabPath,
299+
tabPaneOverrides,
295300
dockVisible,
296301
resolveTab,
297302
closeTab,
@@ -316,6 +321,20 @@ export async function popOutPaneAction(options: PopOutPaneActionOptions): Promis
316321
editorInstanceRef,
317322
dockInstanceRef,
318323
terminalTabPath,
324+
buildEditorPopoutTransfer: (panePath: string) => {
325+
if (!panePath || panePath === terminalTabPath) return null;
326+
const instance = editorInstanceRef.current;
327+
const content = typeof instance?.getContent === 'function' ? instance.getContent() : undefined;
328+
const isDirty = typeof instance?.isDirty === 'function' ? instance.isDirty() : false;
329+
const paneOverrideId = tabPaneOverrides instanceof Map ? (tabPaneOverrides.get(panePath) || null) : null;
330+
const viewState = tabStore.getViewState(panePath) || null;
331+
return createEditorPopoutTransferPayload({
332+
path: panePath,
333+
content: isDirty ? content : undefined,
334+
paneOverrideId,
335+
viewState,
336+
});
337+
},
319338
}),
320339
closeSourcePaneIfTransferred: (panePath: string) => {
321340
closeTransferredPaneSource({
@@ -443,6 +462,7 @@ export interface UseBranchPaneLifecycleOptions {
443462
editorInstanceRef: RefBox<PaneTransferInstanceLike | null>;
444463
dockInstanceRef: RefBox<PaneTransferInstanceLike | null>;
445464
terminalTabPath: string;
465+
tabPaneOverrides: Map<string, string> | null;
446466
dockVisible: boolean;
447467
resolveTab: (path: string) => { dirty?: boolean } | null | undefined;
448468
closeTab: (path: string) => void;
@@ -493,6 +513,7 @@ export function useBranchPaneLifecycle(options: UseBranchPaneLifecycleOptions) {
493513
editorInstanceRef,
494514
dockInstanceRef,
495515
terminalTabPath,
516+
tabPaneOverrides,
496517
dockVisible,
497518
resolveTab,
498519
closeTab,
@@ -614,12 +635,13 @@ export function useBranchPaneLifecycle(options: UseBranchPaneLifecycleOptions) {
614635
editorInstanceRef,
615636
dockInstanceRef,
616637
terminalTabPath,
638+
tabPaneOverrides,
617639
dockVisible,
618640
resolveTab,
619641
closeTab,
620642
setDockVisible,
621643
});
622-
}, [activateTab, closeTab, currentChatJid, dockInstanceRef, dockVisible, editorInstanceRef, isWebAppMode, resolveTab, setDockVisible, showIntentToast, tabStripActiveId, terminalTabPath]);
644+
}, [activateTab, closeTab, currentChatJid, dockInstanceRef, dockVisible, editorInstanceRef, isWebAppMode, resolveTab, setDockVisible, showIntentToast, tabPaneOverrides, tabStripActiveId, terminalTabPath]);
623645

624646
useEffect(() => watchPaneOpenEventBridge({
625647
openEditor,

runtime/web/src/ui/app-branch-pane-orchestration.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ interface PaneTransferInstanceLike {
88
preparePopoutTransfer?: () => Promise<Record<string, string> | null> | Record<string, string> | null;
99
}
1010

11+
interface EditorPopoutTransferBuilder {
12+
(panePath: string): Record<string, string> | null;
13+
}
14+
1115
export interface NavigateToSelectedBranchOptions {
1216
hasWindow?: boolean;
1317
nextChatJid: unknown;
@@ -44,6 +48,7 @@ export interface ResolvePanePopoutTransferOptions {
4448
terminalTabPath: string;
4549
activateTab?: (path: string) => void;
4650
getActiveTabId?: () => string | null;
51+
buildEditorPopoutTransfer?: EditorPopoutTransferBuilder;
4752
}
4853

4954
function normalizePanePath(value: string | null | undefined): string {
@@ -64,6 +69,7 @@ export async function resolvePanePopoutTransfer(options: ResolvePanePopoutTransf
6469
terminalTabPath,
6570
activateTab,
6671
getActiveTabId,
72+
buildEditorPopoutTransfer,
6773
} = options;
6874

6975
if (panePath === terminalTabPath) {
@@ -102,11 +108,14 @@ export async function resolvePanePopoutTransfer(options: ResolvePanePopoutTransf
102108
}
103109
}
104110

105-
if (typeof sourceInstance?.preparePopoutTransfer !== 'function') {
106-
return null;
111+
if (typeof sourceInstance?.preparePopoutTransfer === 'function') {
112+
const explicitTransfer = await sourceInstance.preparePopoutTransfer();
113+
if (explicitTransfer) {
114+
return explicitTransfer;
115+
}
107116
}
108117

109-
return await sourceInstance.preparePopoutTransfer();
118+
return buildEditorPopoutTransfer?.(panePath) ?? null;
110119
}
111120

112121
export interface CloseTransferredPaneSourceOptions {

runtime/web/src/ui/app-main-action-composition.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ interface ComposeBranchPaneActionOptionsInput {
159159
editorInstanceRef: RefBox<any>;
160160
dockInstanceRef: RefBox<any>;
161161
terminalTabPath: string;
162+
tabPaneOverrides: Map<string, string> | null;
162163
dockVisible: boolean;
163164
resolveTab: (path: string) => { dirty?: boolean } | null | undefined;
164165
closeTab: (path: string) => void;
@@ -208,6 +209,7 @@ export function composeBranchPaneActionOptions(input: ComposeBranchPaneActionOpt
208209
editorInstanceRef: input.editorInstanceRef,
209210
dockInstanceRef: input.dockInstanceRef,
210211
terminalTabPath: input.terminalTabPath,
212+
tabPaneOverrides: input.tabPaneOverrides,
211213
dockVisible: input.dockVisible,
212214
resolveTab: input.resolveTab,
213215
closeTab: input.closeTab,

0 commit comments

Comments
 (0)