mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #8748 from ttmouse/pr/chat-panel-resize-min-400
feat(desktop): refine workspace dock sizing and state / 优化工作区面板宽度与状态管理
This commit is contained in:
31 files changed
+2607
-829
No files matched your search
@@ -17,7 +17,7 @@
|
||||
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/terminal-theme.test.ts && tsx src/__tests__/terminal-selection.test.ts && tsx src/__tests__/workspace-layout.test.ts",
|
||||
"test:usage-stats": "tsx src/__tests__/usage-stats-format.test.ts && tsx src/__tests__/usage-stats-panel.test.tsx",
|
||||
"test:task-monitor": "tsx src/components/TaskMonitorPanel.test.tsx && tsx src/__tests__/task-monitor-navigation.test.ts",
|
||||
"test:workspace": "tsx src/__tests__/keyed-resource.test.ts && tsx src/__tests__/workspace-tree-memory.test.ts && tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-turn-verification.test.tsx && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/workspace-context-menu.test.tsx && tsx src/__tests__/workspace-panel-swr-contract.test.ts",
|
||||
"test:workspace": "tsx src/__tests__/keyed-resource.test.ts && tsx src/__tests__/workspace-tree-memory.test.ts && tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-selection-isolation.test.tsx && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-turn-verification.test.tsx && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/workspace-context-menu.test.tsx && tsx src/__tests__/workspace-resize-interaction.test.tsx && tsx src/__tests__/workspace-panel-swr-contract.test.ts",
|
||||
"test:settings-responsive": "tsx src/__tests__/settings-responsive-layout.test.ts",
|
||||
"test:stream": "tsx src/__tests__/raf-batch.test.ts && tsx src/__tests__/stream-delta-batch.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts",
|
||||
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
|
||||
|
||||
@@ -121,7 +121,11 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
|
||||
// its combined diagnostic wiring adds 2.2 KiB (0.094%) to the test channel.
|
||||
// DingTalk startup wiring moves current-base production from 2341.0 to 2343.6
|
||||
// KiB and test from 2346.2 to 2348.8 KiB; the pinned heading adds 0.5 KiB raw
|
||||
// (0.021%). Preserve its scoped UI contract and 0.1 KiB build-SHA headroom.
|
||||
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_349.4 : 2_344.2;
|
||||
// (0.021%). The workspace panel rework (change-row hover/revert, status badges,
|
||||
// More menu, completion summary) makes the latest-base merge 2353.1 KiB in
|
||||
// production and 2358.3 KiB in test: about 9.0 KiB (0.38%) over main-v2's
|
||||
// channel gates. Retain that attributable UI capacity with 0.1 KiB of build-SHA
|
||||
// headroom without widening the gzip or largest-chunk exceptions.
|
||||
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_358.4 : 2_353.2;
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
|
||||
@@ -23,9 +23,11 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
|
||||
"terminal-theme.test.ts": "test:terminal",
|
||||
"task-monitor-navigation.test.ts": "test:task-monitor",
|
||||
"workspace-refresh-store.test.ts": "test:workspace",
|
||||
"workspace-selection-isolation.test.tsx": "test:workspace",
|
||||
"workspace-changes-errors.test.tsx": "test:workspace",
|
||||
"workspace-preview-css.test.ts": "test:workspace",
|
||||
"workspace-context-menu.test.tsx": "test:workspace",
|
||||
"workspace-resize-interaction.test.tsx": "test:workspace",
|
||||
"rich-composer-selection.test.tsx": "pretest",
|
||||
"context-center-contract.test.ts": "pretest",
|
||||
"provider-model-cache.test.ts": "pretest",
|
||||
|
||||
+110
-194
@@ -5,7 +5,6 @@ import {
|
||||
Activity,
|
||||
Command,
|
||||
Copy as RestoreIcon,
|
||||
Download,
|
||||
Minus,
|
||||
Search,
|
||||
Server,
|
||||
@@ -13,14 +12,10 @@ import {
|
||||
SquarePen,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
FileDown,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileJson,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Settings as SettingsIcon,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
AlarmClock,
|
||||
@@ -75,6 +70,7 @@ import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
|
||||
import "./custom/features/heartbeat/heartbeat.css";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { ExternalOpener, shouldMountExternalOpener } from "./components/ExternalOpener";
|
||||
import { TopicbarMoreMenu } from "./components/TopicbarMoreMenu";
|
||||
import { startTerminalEventBridge } from "./lib/terminalEvents";
|
||||
import { applyTerminalThemePreference } from "./lib/terminalTheme";
|
||||
import { formatTerminalOutputForComposer } from "./lib/terminalOutput";
|
||||
@@ -145,11 +141,7 @@ import {
|
||||
CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH,
|
||||
CREATION_RIGHT_DOCK_TREE_MIN_WIDTH,
|
||||
CREATION_SIDEBAR_MIN_WIDTH,
|
||||
RIGHT_DOCK_MAX_WIDTH,
|
||||
RIGHT_DOCK_MIN_RENDER_WIDTH,
|
||||
RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH,
|
||||
RIGHT_DOCK_PREVIEW_MIN_WIDTH,
|
||||
RIGHT_DOCK_TREE_MAX_WIDTH,
|
||||
RIGHT_DOCK_TREE_MIN_WIDTH,
|
||||
type RightDockMode,
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
@@ -159,7 +151,6 @@ import {
|
||||
applyLayoutStyleDefaults,
|
||||
clampCreationRightDockTreeWidth,
|
||||
clampCreationSidebarWidth,
|
||||
clampRightDockPreviewWidth,
|
||||
clampRightDockTreeWidth,
|
||||
clampSidebarWidth,
|
||||
clampTerminalHeight,
|
||||
@@ -167,7 +158,6 @@ import {
|
||||
defaultCreationSidebarWidth,
|
||||
defaultRightDockTreeWidth,
|
||||
defaultSidebarWidth,
|
||||
saveRightDockPreviewWidth,
|
||||
saveRightDockTreeWidth,
|
||||
saveSidebarCollapsed,
|
||||
saveSidebarWidth,
|
||||
@@ -175,6 +165,7 @@ import {
|
||||
saveTerminalPanelOpen,
|
||||
terminalMaxHeight,
|
||||
saveWorkspacePanelOpen,
|
||||
loadWorkspacePanelOpen,
|
||||
useLayoutStore,
|
||||
} from "./store/layout";
|
||||
import { useOverlayStore } from "./store/overlays";
|
||||
@@ -199,9 +190,9 @@ import { applyConfiguredBaseAppearance, applyThemePack, applyThemeScene, clearTh
|
||||
import { ThemeBackground } from "./components/ThemeBackground";
|
||||
import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize";
|
||||
import { useViewportHeightVar, useWindowStatePersistence } from "./lib/windowState";
|
||||
import { resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
|
||||
import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
|
||||
import { createPointerResizeLifecycle, createRafResizeUpdater } from "./lib/resizeDrag";
|
||||
import { useGlobalShortcut } from "./lib/keyboardShortcuts";
|
||||
import { formatShortcutCombo, resolvedShortcutCombo, useGlobalShortcut } from "./lib/keyboardShortcuts";
|
||||
import { useWarmTerminalPanel } from "./lib/useWarmTerminalPanel";
|
||||
import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "./lib/topicShortcuts";
|
||||
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
|
||||
@@ -300,7 +291,6 @@ const WorkspacePanel = lazy(async () => {
|
||||
});
|
||||
|
||||
const CHAT_MIN_WIDTH = 400;
|
||||
const CHAT_COMFORT_MIN_WIDTH = 560;
|
||||
const WORKSPACE_RESIZER_WIDTH = 8;
|
||||
|
||||
function stripLegacyGoalBudgetFlags(arg: string): string {
|
||||
@@ -1204,7 +1194,6 @@ export default function App() {
|
||||
const rightDockTreeWidth = useLayoutStore((s) => s.rightDockTreeWidth);
|
||||
const setRightDockTreeWidth = useLayoutStore((s) => s.setRightDockTreeWidth);
|
||||
const rightDockPreviewWidth = useLayoutStore((s) => s.rightDockPreviewWidth);
|
||||
const setRightDockPreviewWidth = useLayoutStore((s) => s.setRightDockPreviewWidth);
|
||||
const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive);
|
||||
const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive);
|
||||
const attentionChimeEvents = useRef(new Set<string>());
|
||||
@@ -1297,7 +1286,6 @@ export default function App() {
|
||||
const sidebarSearchFocusSignal = useOverlayStore((s) => s.sidebarSearchFocusSignal);
|
||||
const setSidebarSearchFocusSignal = useOverlayStore((s) => s.setSidebarSearchFocusSignal);
|
||||
const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false);
|
||||
const [workspaceTogglePressed, setWorkspaceTogglePressed] = useState(false);
|
||||
const [clearContextPending, setClearContextPending] = useState(false);
|
||||
const [backgroundRuntimes, setBackgroundRuntimes] = useState<BackgroundRuntimeView[]>([]);
|
||||
const [workspaceConflict, setWorkspaceConflict] = useState<WorkspaceConflictView | null>(null);
|
||||
@@ -1310,7 +1298,6 @@ export default function App() {
|
||||
const layoutRef = useRef<HTMLDivElement>(null);
|
||||
const workspacePanelResizeFinishRef = useRef<(() => void) | null>(null);
|
||||
const sidebarTogglePressTimerRef = useRef<number | null>(null);
|
||||
const workspaceTogglePressTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Persist window geometry across launches.
|
||||
useWindowStatePersistence();
|
||||
@@ -1410,18 +1397,6 @@ export default function App() {
|
||||
}, 260);
|
||||
}, []);
|
||||
|
||||
const pulseWorkspaceToggle = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (workspaceTogglePressTimerRef.current !== null) {
|
||||
window.clearTimeout(workspaceTogglePressTimerRef.current);
|
||||
}
|
||||
setWorkspaceTogglePressed(true);
|
||||
workspaceTogglePressTimerRef.current = window.setTimeout(() => {
|
||||
workspaceTogglePressTimerRef.current = null;
|
||||
setWorkspaceTogglePressed(false);
|
||||
}, 260);
|
||||
}, []);
|
||||
|
||||
const anchorAppScrollToChat = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const el = appRef.current;
|
||||
@@ -1439,9 +1414,6 @@ export default function App() {
|
||||
if (sidebarTogglePressTimerRef.current !== null) {
|
||||
window.clearTimeout(sidebarTogglePressTimerRef.current);
|
||||
}
|
||||
if (workspaceTogglePressTimerRef.current !== null) {
|
||||
window.clearTimeout(workspaceTogglePressTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1592,14 +1564,24 @@ export default function App() {
|
||||
});
|
||||
}, []);
|
||||
const rightDockDetailActive = rightDockMode !== "context" && workspacePreviewActive;
|
||||
const preferredWorkspacePanelWidth = rightDockDetailActive ? rightDockPreviewWidth : rightDockTreeWidth;
|
||||
// The dock keeps one width across tab switches (context/files/changed):
|
||||
// the tree width is the single source so toggling tabs never resizes the
|
||||
// sidebar. Preview detail stays inside the dock without widening it.
|
||||
const preferredWorkspacePanelWidth = rightDockTreeWidth;
|
||||
const rightDockTreeMinWidth = desktopLayoutStyle === "creation" ? CREATION_RIGHT_DOCK_TREE_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH;
|
||||
const rightDockTreeWidthClamp = desktopLayoutStyle === "creation" ? clampCreationRightDockTreeWidth : clampRightDockTreeWidth;
|
||||
const rightDockMinRenderWidth = desktopLayoutStyle === "creation" && !rightDockDetailActive
|
||||
? CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH
|
||||
: RIGHT_DOCK_MIN_RENDER_WIDTH;
|
||||
const workspacePanelMinWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth;
|
||||
const chatReservedWidth = workspacePanelOpen && !workspacePanelMaximized ? CHAT_COMFORT_MIN_WIDTH : CHAT_MIN_WIDTH;
|
||||
const workspacePanelMinWidth = rightDockTreeMinWidth;
|
||||
const chatReservedWidth = CHAT_MIN_WIDTH;
|
||||
const workspacePanelAvailableWidth = availableWorkspacePanelWidth({
|
||||
viewportWidth,
|
||||
sidebarCollapsed,
|
||||
sidebarWidth,
|
||||
chatMinWidth: chatReservedWidth,
|
||||
resizerWidth: WORKSPACE_RESIZER_WIDTH,
|
||||
});
|
||||
const {
|
||||
renderWidth: workspacePanelRenderWidth,
|
||||
overlay: workspacePanelOverlay,
|
||||
@@ -1667,10 +1649,12 @@ export default function App() {
|
||||
activeTab?.scope ?? "",
|
||||
activeTab?.workspaceRoot ?? state.meta?.cwd ?? "",
|
||||
].join("\u0000");
|
||||
const restoreWorkspaceDockWidths = useCallback((treeWidth: number, previewWidth: number) => {
|
||||
setRightDockTreeWidth(rightDockTreeWidthClamp(treeWidth));
|
||||
setRightDockPreviewWidth(clampRightDockPreviewWidth(previewWidth));
|
||||
}, [rightDockTreeWidthClamp]);
|
||||
const restoreWorkspaceDockWidths = useCallback((treeWidth: number, _previewWidth: number) => {
|
||||
// Single-width dock: only the tree width is meaningful; clamp it to the
|
||||
// dynamic available width (chat keeps its 400px floor), never a fixed
|
||||
// 560 ceiling, so the user's remembered width is preserved when reopened.
|
||||
setRightDockTreeWidth(rightDockTreeWidthClamp(treeWidth, workspacePanelAvailableWidth));
|
||||
}, [rightDockTreeWidthClamp, workspacePanelAvailableWidth]);
|
||||
const sidebarImDetailConnection = useMemo(
|
||||
() => sidebarImConnections.find((connection) => connection.id === sidebarImDetailConnectionId) ?? null,
|
||||
[sidebarImConnections, sidebarImDetailConnectionId],
|
||||
@@ -2717,28 +2701,22 @@ export default function App() {
|
||||
const setSavedWorkspacePanelWidth = useCallback(
|
||||
(width: number) => {
|
||||
closeTransientOverlays();
|
||||
if (rightDockDetailActive) {
|
||||
const next = clampRightDockPreviewWidth(width);
|
||||
setRightDockPreviewWidth(next);
|
||||
saveRightDockPreviewWidth(next);
|
||||
return;
|
||||
}
|
||||
const next = rightDockTreeWidthClamp(width);
|
||||
const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth);
|
||||
setRightDockTreeWidth(next);
|
||||
saveRightDockTreeWidth(next);
|
||||
},
|
||||
[closeTransientOverlays, rightDockDetailActive, rightDockTreeWidthClamp],
|
||||
[closeTransientOverlays, rightDockTreeWidthClamp, workspacePanelAvailableWidth],
|
||||
);
|
||||
|
||||
const ensureWorkspacePanelWidth = useCallback(
|
||||
(width: number) => {
|
||||
closeTransientOverlays();
|
||||
if (rightDockMode === "context") return;
|
||||
const next = clampRightDockPreviewWidth(width);
|
||||
setRightDockPreviewWidth(next);
|
||||
saveRightDockPreviewWidth(next);
|
||||
const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth);
|
||||
setRightDockTreeWidth(next);
|
||||
saveRightDockTreeWidth(next);
|
||||
},
|
||||
[closeTransientOverlays, rightDockMode],
|
||||
[closeTransientOverlays, rightDockTreeWidthClamp, workspacePanelAvailableWidth],
|
||||
);
|
||||
|
||||
const startWorkspacePanelResize = useCallback(
|
||||
@@ -2764,11 +2742,7 @@ export default function App() {
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
const delta = moveEvent.clientX - startX;
|
||||
nextDockWidth = startDockWidth - delta;
|
||||
if (rightDockDetailActive) {
|
||||
nextDockWidth = clampRightDockPreviewWidth(nextDockWidth);
|
||||
} else {
|
||||
nextDockWidth = rightDockTreeWidthClamp(nextDockWidth);
|
||||
}
|
||||
nextDockWidth = rightDockTreeWidthClamp(nextDockWidth, workspacePanelAvailableWidth);
|
||||
liveResize.schedule(resolveLiveWorkspacePanelRenderWidth(nextDockWidth));
|
||||
};
|
||||
const lifecycle = createPointerResizeLifecycle({
|
||||
@@ -2789,7 +2763,7 @@ export default function App() {
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
},
|
||||
[closeTransientOverlays, resolveLiveWorkspacePanelRenderWidth, rightDockDetailActive, rightDockTreeWidthClamp, setSavedWorkspacePanelWidth, workspacePanelOpen, workspacePanelRenderWidth],
|
||||
[closeTransientOverlays, resolveLiveWorkspacePanelRenderWidth, rightDockDetailActive, rightDockTreeWidthClamp, setSavedWorkspacePanelWidth, workspacePanelAvailableWidth, workspacePanelOpen, workspacePanelRenderWidth],
|
||||
);
|
||||
|
||||
const resizeWorkspacePanelWithKeyboard = useCallback(
|
||||
@@ -2799,13 +2773,13 @@ export default function App() {
|
||||
setSavedWorkspacePanelWidth(workspacePanelRenderWidth + (event.key === "ArrowLeft" ? 16 : -16));
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth);
|
||||
setSavedWorkspacePanelWidth(rightDockTreeMinWidth);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH);
|
||||
setSavedWorkspacePanelWidth(workspacePanelAvailableWidth);
|
||||
}
|
||||
},
|
||||
[rightDockDetailActive, rightDockTreeMinWidth, setSavedWorkspacePanelWidth, workspacePanelRenderWidth],
|
||||
[rightDockDetailActive, rightDockTreeMinWidth, setSavedWorkspacePanelWidth, workspacePanelAvailableWidth, workspacePanelRenderWidth],
|
||||
);
|
||||
|
||||
const terminalRenderHeight = clampTerminalHeight(terminalHeight, viewportHeight);
|
||||
@@ -2876,6 +2850,8 @@ export default function App() {
|
||||
[setSavedTerminalHeight, terminalPanelOpen, terminalRenderHeight, terminalResizeMaxHeight],
|
||||
);
|
||||
|
||||
const activeWorkspaceRoot = activeTab?.workspaceRoot ?? state.meta?.cwd ?? "";
|
||||
|
||||
const openWorkspacePanel = useCallback(
|
||||
(mode: RightDockMode = rightDockMode) => {
|
||||
closeTransientOverlays();
|
||||
@@ -2897,9 +2873,9 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
setWorkspacePanelOpen(true);
|
||||
saveWorkspacePanelOpen(true);
|
||||
saveWorkspacePanelOpen(true, activeWorkspaceRoot);
|
||||
},
|
||||
[closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen],
|
||||
[activeWorkspaceRoot, closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen],
|
||||
);
|
||||
|
||||
const closeWorkspacePanel = useCallback(() => {
|
||||
@@ -2910,11 +2886,18 @@ export default function App() {
|
||||
setLiveWorkspacePanelRenderWidth(null);
|
||||
setWorkspacePanelMaximized(false);
|
||||
setWorkspacePanelOpen(false);
|
||||
saveWorkspacePanelOpen(false);
|
||||
}, [closeTransientOverlays, workspacePanelOpen]);
|
||||
saveWorkspacePanelOpen(false, activeWorkspaceRoot);
|
||||
}, [activeWorkspaceRoot, closeTransientOverlays, workspacePanelOpen]);
|
||||
|
||||
// Restore the right dock's open/closed state per project: switching to a
|
||||
// different workspace root (or a global session) restores that scope's own
|
||||
// preference instead of carrying the previous project's state over.
|
||||
useEffect(() => {
|
||||
setWorkspacePanelOpen(loadWorkspacePanelOpen(activeWorkspaceRoot));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeWorkspaceRoot]);
|
||||
|
||||
const toggleWorkspacePanel = useCallback(() => {
|
||||
pulseWorkspaceToggle();
|
||||
if (workspacePanelRenderable) {
|
||||
closeWorkspacePanel();
|
||||
return;
|
||||
@@ -2925,8 +2908,10 @@ export default function App() {
|
||||
openWorkspacePanel(rightDockMode === "changed" ? "changed" : "files");
|
||||
return;
|
||||
}
|
||||
openWorkspacePanel("context");
|
||||
}, [closeWorkspacePanel, desktopLayoutStyle, openWorkspacePanel, pulseWorkspaceToggle, rightDockMode, workspacePanelRenderable]);
|
||||
// Reopen with the previously active tab (rightDockMode is kept in the
|
||||
// store across close/open) instead of forcing "context".
|
||||
openWorkspacePanel();
|
||||
}, [closeWorkspacePanel, desktopLayoutStyle, openWorkspacePanel, rightDockMode, workspacePanelRenderable]);
|
||||
|
||||
const openRightDockMode = useCallback(
|
||||
(mode: RightDockMode) => {
|
||||
@@ -4221,14 +4206,36 @@ export default function App() {
|
||||
const browserPreviewChrome = typeof window !== "undefined" && !window.runtime;
|
||||
const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : "";
|
||||
const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined;
|
||||
const workspacePanelResetWidth = rightDockDetailActive
|
||||
? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH
|
||||
: desktopLayoutStyle === "creation"
|
||||
? defaultCreationRightDockTreeWidth()
|
||||
: defaultRightDockTreeWidth();
|
||||
const workspacePanelResetWidth = desktopLayoutStyle === "creation"
|
||||
? defaultCreationRightDockTreeWidth()
|
||||
: defaultRightDockTreeWidth();
|
||||
const workspacePanelResizeMinWidth = workspacePanelAriaMinWidth(workspacePanelMinWidth, workspacePanelRenderWidth);
|
||||
const workspacePanelMaxWidth = rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH;
|
||||
const workspacePanelResizeMaxWidth = workspacePanelAvailableWidth;
|
||||
const sidebarCreation = desktopLayoutStyle === "creation";
|
||||
// Command palette shortcut label (⌘K / Ctrl+K), platform-aware.
|
||||
const commandPaletteShortcut = formatShortcutCombo(
|
||||
resolvedShortcutCombo("commandPalette.open", desktopPlatform),
|
||||
desktopPlatform,
|
||||
);
|
||||
// Dock collapse/expand toggle. Rendered in the dock's own tools row when the
|
||||
// dock is open (its top-right corner), and in the topic bar when closed.
|
||||
const dockToggleButton = (
|
||||
<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
|
||||
<button
|
||||
className={[
|
||||
"topicbar__chrome-btn",
|
||||
"topicbar__chrome-btn--workspace",
|
||||
workspacePanelRenderable ? "topicbar__chrome-btn--active" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
type="button"
|
||||
onClick={toggleWorkspacePanel}
|
||||
aria-label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
|
||||
aria-pressed={workspacePanelRenderable}
|
||||
>
|
||||
<PanelRight size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
const topicbarTitle = sidebarImDetailConnection ? t("botDetail.title", { name: sidebarImDetailConnection.title }) : topicDisplayTitle(activeTab);
|
||||
const topicbarWorkspaceLabel = sidebarImDetailConnection ? t("botDetail.subtitle") : activeTab ? tabWorkspaceTitle(activeTab) : "";
|
||||
const topicbarWorkspacePath = activeTab?.scope === "project" ? activeTab.workspaceRoot || state.meta?.cwd : "";
|
||||
@@ -4237,7 +4244,7 @@ export default function App() {
|
||||
? sidebarImDetailConnection.platformLabel
|
||||
: topicbarImSource ? t("msg.fromIm", { source: topicbarImSource.label }) : "";
|
||||
const topicbarImSourcePlatform = sidebarImDetailConnection?.platform ?? topicbarImSource?.platform;
|
||||
const topicbarSubtitleVisible = !sidebarCreation && Boolean(topicbarWorkspaceLabel || topicbarImSourceLabel);
|
||||
const topicbarSubtitleVisible = !sidebarCreation && Boolean(activeTab?.isolatedWorktree || topicbarImSourceLabel);
|
||||
const topicbarSubtitleTitle = sidebarImDetailConnection
|
||||
? [topicbarWorkspaceLabel, topicbarImSourceLabel, sidebarImScopeLabel(sidebarImDetailConnection, t)].filter(Boolean).join(" · ")
|
||||
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
|
||||
@@ -4289,6 +4296,7 @@ export default function App() {
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<ThemeBackground />
|
||||
{sidebarWorkbench && <div className="app__dock-toggle">{dockToggleButton}</div>}
|
||||
<div
|
||||
ref={layoutRef}
|
||||
className={[
|
||||
@@ -4326,7 +4334,6 @@ export default function App() {
|
||||
sidebarToggleTitle={sidebarToggleTitle}
|
||||
workspacePanelMaximized={workspacePanelMaximized}
|
||||
workspacePanelRenderable={workspacePanelRenderable}
|
||||
workspaceTogglePressed={workspaceTogglePressed}
|
||||
workspacePanelLabel={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onToggleWorkspacePanel={toggleWorkspacePanel}
|
||||
@@ -4609,6 +4616,7 @@ export default function App() {
|
||||
size={sidebarCreation ? topicbarTitleEditSize : undefined}
|
||||
value={topicTitleDraft}
|
||||
onChange={(event) => setTopicTitleDraft(event.target.value)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
@@ -4622,7 +4630,7 @@ export default function App() {
|
||||
onBlur={() => void commitActiveTopicRename()}
|
||||
/>
|
||||
</div>
|
||||
) : sidebarCreation && topicbarCanRename ? (
|
||||
) : topicbarCanRename ? (
|
||||
<h1 title={topicTitle(activeTab)}>
|
||||
<button
|
||||
className="topicbar__title-button"
|
||||
@@ -4636,23 +4644,14 @@ export default function App() {
|
||||
) : (
|
||||
<h1 title={sidebarImDetailConnection ? topicbarTitle : topicTitle(activeTab)}>{topicbarTitle}</h1>
|
||||
)}
|
||||
{!sidebarCreation && (
|
||||
<Tooltip label={t("topicBar.renameSession")}>
|
||||
<button
|
||||
className="topicbar__icon-btn"
|
||||
type="button"
|
||||
disabled={!topicbarCanRename || topicbarEditing}
|
||||
onClick={startActiveTopicRename}
|
||||
aria-label={t("topicBar.renameSession")}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{topicbarWorkspaceLabel && (
|
||||
<span className="topicbar__workspace-label" title={topicbarWorkspaceLabel}>
|
||||
{topicbarWorkspaceLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{topicbarSubtitleVisible && (
|
||||
<div className="topicbar__subtitle" title={topicbarSubtitleTitle}>
|
||||
{topicbarWorkspaceLabel && <span>{topicbarWorkspaceLabel}</span>}
|
||||
{activeTab?.isolatedWorktree && <WorktreeBadge size={11} />}
|
||||
{topicbarImSourcePlatform && (
|
||||
<span className={`topicbar__source-chip topicbar__source-chip--${topicbarImSourcePlatform}`}>
|
||||
@@ -4664,116 +4663,32 @@ export default function App() {
|
||||
</div>
|
||||
<div className="topicbar__spacer" />
|
||||
<div className="topicbar__actions">
|
||||
{sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
|
||||
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
)}
|
||||
{!sidebarImDetailConnection && (
|
||||
<>
|
||||
<Tooltip label={t("topicBar.copyAll")}>
|
||||
<CopyButton
|
||||
getText={getSessionMarkdown}
|
||||
label={t("topicBar.copyAll")}
|
||||
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
|
||||
showInlineLabel={false}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className={`topicbar__export${topicExportOpen ? " topicbar__export--open" : ""}`}>
|
||||
<Tooltip label={t("topicBar.export")}>
|
||||
<button
|
||||
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
|
||||
type="button"
|
||||
disabled={!sessionHasContent}
|
||||
aria-label={t("topicBar.export")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={topicExportOpen}
|
||||
onClick={() => setTopicExportOpen((open) => !open)}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{topicExportOpen && (
|
||||
<div className="topicbar__export-menu" role="menu">
|
||||
<button type="button" role="menuitem" onClick={() => void exportSession("markdown")}>
|
||||
<FileText size={13} />
|
||||
<span>{t("topicBar.exportMarkdown")}</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onClick={() => void exportSession("json")}>
|
||||
<FileJson size={13} />
|
||||
<span>{t("topicBar.exportJson")}</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onClick={() => void exportSession("pdf")}>
|
||||
<FileDown size={13} />
|
||||
<span>{t("topicBar.exportPdf")}</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onClick={() => void exportSession("image")}>
|
||||
<FileImage size={13} />
|
||||
<span>{t("topicBar.exportImage")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!sidebarCreation && (
|
||||
<Tooltip label={t("workspace.changedTab")}>
|
||||
<button
|
||||
className="topicbar__action-btn topicbar__action-btn--label"
|
||||
type="button"
|
||||
aria-label={t("workspace.changedTab")}
|
||||
aria-pressed={workspacePanelRenderable && rightDockMode === "changed"}
|
||||
onClick={() => openRightDockMode("changed")}
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
<span>{t("workspace.changedTab")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!sidebarImDetailConnection && (
|
||||
<Tooltip label={t("rightDock.terminal")}>
|
||||
<button
|
||||
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
|
||||
type="button"
|
||||
aria-label={t("rightDock.terminal")}
|
||||
aria-pressed={terminalPanelOpen}
|
||||
onPointerEnter={prefetchTerminalPanel} onFocus={prefetchTerminalPanel}
|
||||
onClick={toggleTerminalPanel}
|
||||
>
|
||||
<TerminalSquare size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
|
||||
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
)}
|
||||
<Tooltip label={t("summary.session")}>
|
||||
<Tooltip label={`${t("shortcuts.action.commandPalette")} ${commandPaletteShortcut}`}>
|
||||
<button
|
||||
className={`topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility${tasksOpen ? " topicbar__action-btn--active" : ""}`}
|
||||
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
|
||||
type="button"
|
||||
aria-label={t("summary.session")}
|
||||
aria-expanded={Boolean(tasksOpen)}
|
||||
onClick={() => setTasksOpen((open) => open ? false : "session")}
|
||||
aria-label={t("shortcuts.action.commandPalette")}
|
||||
onClick={() => void openPalette()}
|
||||
>
|
||||
<Activity size={14} />
|
||||
<Search size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{(sidebarCreation || workbenchChromeHidden) && (
|
||||
<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
|
||||
<button
|
||||
className={[
|
||||
"topicbar__chrome-btn",
|
||||
"topicbar__chrome-btn--workspace",
|
||||
workspacePanelRenderable ? "topicbar__chrome-btn--active" : "",
|
||||
workspaceTogglePressed ? "topicbar__chrome-btn--pressed" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
type="button"
|
||||
onClick={toggleWorkspacePanel}
|
||||
aria-label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
|
||||
aria-pressed={workspacePanelRenderable}
|
||||
>
|
||||
<PanelRight size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
|
||||
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
)}
|
||||
{!sidebarImDetailConnection && (
|
||||
<TopicbarMoreMenu
|
||||
sessionHasContent={sessionHasContent}
|
||||
getSessionMarkdown={getSessionMarkdown}
|
||||
exportSession={(format) => void exportSession(format)}
|
||||
openChangedDock={() => openRightDockMode("changed")}
|
||||
toggleTerminal={toggleTerminalPanel}
|
||||
prefetchTerminal={prefetchTerminalPanel}
|
||||
openSessionSummary={() => setTasksOpen((open) => open ? false : "session")}
|
||||
tasksOpen={Boolean(tasksOpen)}
|
||||
/>
|
||||
)}
|
||||
{sidebarCreation && dockToggleButton}
|
||||
{tasksOpen && (
|
||||
<div className="taskmonitor-popover" role="dialog" aria-label={t("summary.session")}>
|
||||
<Suspense fallback={null}>
|
||||
@@ -5162,7 +5077,7 @@ export default function App() {
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("rightDock.resize")}
|
||||
aria-valuemin={workspacePanelResizeMinWidth}
|
||||
aria-valuemax={Math.max(workspacePanelMaxWidth, workspacePanelRenderWidth)}
|
||||
aria-valuemax={Math.max(workspacePanelResizeMaxWidth, workspacePanelRenderWidth)}
|
||||
aria-valuenow={workspacePanelRenderWidth}
|
||||
onPointerDown={startWorkspacePanelResize}
|
||||
onKeyDown={resizeWorkspacePanelWithKeyboard}
|
||||
@@ -5254,6 +5169,7 @@ export default function App() {
|
||||
) : (
|
||||
<Suspense fallback={null}>
|
||||
<WorkspacePanel
|
||||
key={workspaceTreeMemoryKey}
|
||||
open={workspacePanelRenderable}
|
||||
tabId={activeTabId}
|
||||
cwd={state.meta?.cwd}
|
||||
|
||||
@@ -279,10 +279,10 @@ ok(
|
||||
|
||||
ok(
|
||||
/const WORKSPACE_PANEL_DEFAULT_OPEN = true;/.test(layoutStoreSource) &&
|
||||
/workspacePanelOpen:\s*loadWorkspacePanelOpen\(\)/.test(layoutStoreSource) &&
|
||||
/export function saveWorkspacePanelOpen\(open: boolean\)/.test(layoutStoreSource) &&
|
||||
/workspacePanelOpen:\s*loadWorkspacePanelOpen\(""\)/.test(layoutStoreSource) &&
|
||||
/export function saveWorkspacePanelOpen\(open: boolean, workspaceRoot = ""\)/.test(layoutStoreSource) &&
|
||||
/reasonix\.workspacePanel\.open/.test(layoutStoreSource),
|
||||
"right dock open state is restored from localStorage with expanded first-launch default",
|
||||
"right dock open state is restored from per-project localStorage with expanded first-launch default",
|
||||
);
|
||||
|
||||
ok(
|
||||
@@ -502,7 +502,7 @@ ok(
|
||||
|
||||
ok(
|
||||
/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
|
||||
/hydrating=\{transcriptHydrating\}/.test(appSource),
|
||||
/hydrating=\{runtimeTransitioning \|\| transcriptHydrating\}/.test(appSource),
|
||||
"Welcome is suppressed only until transcript history has loaded",
|
||||
);
|
||||
|
||||
@@ -689,8 +689,8 @@ ok(
|
||||
);
|
||||
|
||||
ok(
|
||||
finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "bottom") === "1px",
|
||||
"active dock underline stays inside the visible dock edge",
|
||||
finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "display") === "none",
|
||||
"active dock tab underline is removed in favor of the rounded-rect selected state",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
|
||||
@@ -7,13 +7,17 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
|
||||
const moreMenuSource = [
|
||||
readFileSync(resolve(testDir, "../components/TopicbarMoreMenu.tsx"), "utf8"),
|
||||
readFileSync(resolve(testDir, "../components/TopicbarMoreMenuContent.tsx"), "utf8"),
|
||||
].join("\n");
|
||||
|
||||
assert.doesNotMatch(appSource, /t\("shortcuts\.cheatsheetTitle"\)|t\("topicBar\.command"\)/);
|
||||
|
||||
const taskSummaryControlIndex = appSource.indexOf('<Tooltip label={t("summary.session")}>');
|
||||
const taskSummaryControlIndex = moreMenuSource.indexOf('t("summary.session")');
|
||||
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
|
||||
assert.ok(taskSummaryControlIndex >= 0, "topic bar renders the localized Session summary control");
|
||||
assert.ok(workspaceToggleIndex > taskSummaryControlIndex, "Session summary precedes the right-edge workspace toggle");
|
||||
assert.ok(!appSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label");
|
||||
assert.ok(workspaceToggleIndex >= 0, "topic bar keeps the right-edge workspace toggle");
|
||||
assert.ok(!moreMenuSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label");
|
||||
|
||||
process.stdout.write("topicbar controls: 2 contracts passed\n");
|
||||
@@ -0,0 +1,170 @@
|
||||
// Run: tsx src/__tests__/topicbar-more-menu.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { TopicbarMoreMenu } from "../components/TopicbarMoreMenu";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".css")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
function press(target: Element, key: string) {
|
||||
target.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
function directMenuItems(menu: HTMLElement): HTMLButtonElement[] {
|
||||
return Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
|
||||
.filter((item) => item.closest('[role="menu"]') === menu);
|
||||
}
|
||||
|
||||
console.log("\ntopicbar more menu keyboard navigation");
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<TopicbarMoreMenu
|
||||
sessionHasContent
|
||||
getSessionMarkdown={() => "# Session"}
|
||||
exportSession={() => {}}
|
||||
openChangedDock={() => {}}
|
||||
toggleTerminal={() => {}}
|
||||
openSessionSummary={() => {}}
|
||||
tasksOpen={false}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
const trigger = rootElement.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
|
||||
if (!trigger) throw new Error("missing more-menu trigger");
|
||||
|
||||
await act(async () => {
|
||||
trigger.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("lazy menu content", () => rootElement.querySelector('[role="menu"]') !== null);
|
||||
|
||||
let menu = rootElement.querySelector<HTMLElement>('.topicbar__more-menu[role="menu"]');
|
||||
if (!menu) throw new Error("missing more menu");
|
||||
let items = directMenuItems(menu);
|
||||
ok(document.activeElement === items[0], "opening the menu moves focus to the first action");
|
||||
|
||||
await act(async () => {
|
||||
press(items[0]!, "ArrowDown");
|
||||
});
|
||||
ok(document.activeElement === items[1], "ArrowDown advances focus to the next action");
|
||||
|
||||
await act(async () => {
|
||||
press(items[1]!, "End");
|
||||
});
|
||||
ok(document.activeElement === items[items.length - 1], "End moves focus to the final action");
|
||||
|
||||
await act(async () => {
|
||||
press(items[items.length - 1]!, "Home");
|
||||
});
|
||||
ok(document.activeElement === items[0], "Home returns focus to the first action");
|
||||
|
||||
await act(async () => {
|
||||
items[1]?.focus();
|
||||
press(items[1]!, "ArrowRight");
|
||||
await flushTimers();
|
||||
});
|
||||
const exportMenu = rootElement.querySelector<HTMLElement>('.topicbar__more-export-menu[role="menu"]');
|
||||
if (!exportMenu) throw new Error("missing export submenu");
|
||||
const exportItems = directMenuItems(exportMenu);
|
||||
ok(document.activeElement === exportItems[0], "ArrowRight opens the export submenu and focuses its first action");
|
||||
|
||||
await act(async () => {
|
||||
press(exportItems[0]!, "ArrowDown");
|
||||
});
|
||||
ok(document.activeElement === exportItems[1], "submenu ArrowDown stays within the submenu");
|
||||
|
||||
await act(async () => {
|
||||
press(exportItems[1]!, "ArrowLeft");
|
||||
await flushTimers();
|
||||
});
|
||||
ok(rootElement.querySelector(".topicbar__more-export-menu") === null, "ArrowLeft closes the export submenu");
|
||||
ok(document.activeElement === items[1], "closing the export submenu restores focus to its trigger");
|
||||
|
||||
await act(async () => {
|
||||
press(items[1]!, "Escape");
|
||||
await flushTimers();
|
||||
});
|
||||
ok(rootElement.querySelector(".topicbar__more-menu") === null, "Escape closes the main menu");
|
||||
ok(document.activeElement === trigger, "closing the main menu restores focus to the More trigger");
|
||||
|
||||
await act(async () => {
|
||||
press(trigger, "ArrowUp");
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("keyboard-opened menu", () => rootElement.querySelector(".topicbar__more-menu") !== null);
|
||||
menu = rootElement.querySelector<HTMLElement>('.topicbar__more-menu[role="menu"]');
|
||||
if (!menu) throw new Error("missing keyboard-opened menu");
|
||||
items = directMenuItems(menu);
|
||||
ok(document.activeElement === items[items.length - 1], "ArrowUp on the trigger opens the menu at its final action");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
console.log(`\ntopicbar more menu: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,28 +1,9 @@
|
||||
// Run: tsx src/__tests__/workspace-changes-errors.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { workspaceFileIcon } from "../components/WorkspaceFileIcon";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { DirEntry, FilePreview, GitCommitView, WireCompletionSummary, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
|
||||
|
||||
// Markdown previews lazy-load MarkdownRenderer, whose KaTeX stylesheet belongs
|
||||
// to the same production chunk. Node's tsx loader has no CSS module support,
|
||||
// so map stylesheet imports to the existing empty asset stub in this DOM test.
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".css")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
import type { DirEntry, FilePreview, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
|
||||
import { flushPromises, renderFilesWorkspace, renderWorkspace, waitFor } from "./workspace-panel-test-harness";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -37,169 +18,6 @@ function ok(value: boolean, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
dom.window.ResizeObserver = TestResizeObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
|
||||
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
|
||||
configurable: true,
|
||||
get: () => 320,
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: function offsetHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: function getBoundingClientRect(this: HTMLElement) {
|
||||
const width = 320;
|
||||
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
width,
|
||||
height,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
},
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function renderWorkspace(
|
||||
changes: WorkspaceChangesView,
|
||||
options: { creationMode?: boolean; history?: GitCommitView[]; detail?: WorkspaceChangeDetailView; completionSummary?: WireCompletionSummary } = {},
|
||||
) {
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => options.history ?? [],
|
||||
WorkspaceChanges: async () => changes,
|
||||
WorkspaceChangeDetail: async () => options.detail ?? {},
|
||||
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel
|
||||
open
|
||||
tabId="tab-a"
|
||||
cwd="/repo"
|
||||
maximized={false}
|
||||
initialViewMode="changed"
|
||||
creationMode={options.creationMode}
|
||||
completionSummary={options.completionSummary}
|
||||
onClose={() => {}}
|
||||
onToggleMaximized={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
|
||||
return { dom, root };
|
||||
}
|
||||
|
||||
async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
WorkspaceChangeDetail: async () => ({}),
|
||||
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
|
||||
...methods,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
|
||||
open: true,
|
||||
tabId: "tab-a",
|
||||
cwd: "/repo",
|
||||
maximized: false,
|
||||
initialViewMode: "files",
|
||||
onClose: () => {},
|
||||
onToggleMaximized: () => {},
|
||||
...props,
|
||||
};
|
||||
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel {...currentProps} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
};
|
||||
await rerender();
|
||||
return { dom, root, rerender };
|
||||
}
|
||||
|
||||
console.log("\nworkspace changes git errors");
|
||||
|
||||
{
|
||||
|
||||
@@ -19,6 +19,10 @@ let failed = 0;
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
|
||||
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
const moreMenuSource = [
|
||||
readFileSync(resolve(testDir, "../components/TopicbarMoreMenu.tsx"), "utf8"),
|
||||
readFileSync(resolve(testDir, "../components/TopicbarMoreMenuContent.tsx"), "utf8"),
|
||||
].join("\n");
|
||||
const terminalPanelSource = readFileSync(resolve(testDir, "../components/TerminalPanel.tsx"), "utf8");
|
||||
const terminalViewSource = readFileSync(resolve(testDir, "../components/TerminalView.tsx"), "utf8");
|
||||
const terminalRailSource = readFileSync(resolve(testDir, "../components/TerminalSessionRail.tsx"), "utf8");
|
||||
@@ -160,7 +164,7 @@ eq(terminalMaxHeight(180), 120, "terminal maximum never falls below the accessib
|
||||
eq(clampTerminalHeight(680, 480), 240, "restored terminal height clamps after the window shrinks");
|
||||
eq(clampTerminalHeight(80, 720), 120, "terminal height clamps to its minimum");
|
||||
eq(
|
||||
/const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);[\s\S]*?saveWorkspacePanelOpen\(false\);/.test(appSource),
|
||||
/const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);[\s\S]*?saveWorkspacePanelOpen\(false, activeWorkspaceRoot\);/.test(appSource),
|
||||
true,
|
||||
"closing the dock clears the transient render width, hides the panel, and persists the collapsed preference",
|
||||
);
|
||||
@@ -177,7 +181,7 @@ eq(
|
||||
"workspace resize has one guarded finish path for capture loss, blur, cancellation, and unmount",
|
||||
);
|
||||
eq(
|
||||
/setWorkspacePanelOpen\(true\);[\s\S]*?saveWorkspacePanelOpen\(true\);/.test(appSource),
|
||||
/setWorkspacePanelOpen\(true\);[\s\S]*?saveWorkspacePanelOpen\(true, activeWorkspaceRoot\);/.test(appSource),
|
||||
true,
|
||||
"opening the dock persists the expanded preference for the next launch",
|
||||
);
|
||||
@@ -253,7 +257,8 @@ const workspaceDockTabsSource = appSource.match(/<div className="workbench-dock_
|
||||
eq(
|
||||
workspaceDockTabsSource.length > 0
|
||||
&& !/rightDock\.terminal|terminalPanelOpen|toggleTerminalPanel/.test(workspaceDockTabsSource)
|
||||
&& /className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"[\s\S]*?aria-label=\{t\("rightDock\.terminal"\)\}[\s\S]*?onClick=\{toggleTerminalPanel\}/.test(appSource),
|
||||
&& /<TopicbarMoreMenu[\s\S]*?toggleTerminal=\{toggleTerminalPanel\}/.test(appSource)
|
||||
&& /className="topicbar__menu-item"[\s\S]*?closeAndRun\(toggleTerminal\)[\s\S]*?t\("rightDock\.terminal"\)/.test(moreMenuSource),
|
||||
true,
|
||||
"workspace dock omits the terminal view while the topic bar keeps the terminal drawer action",
|
||||
);
|
||||
@@ -324,11 +329,11 @@ eq(
|
||||
"terminal and xterm remain in a lazy chunk",
|
||||
);
|
||||
eq(
|
||||
/onPointerEnter=\{prefetchTerminalPanel\}/.test(appSource)
|
||||
&& /onFocus=\{prefetchTerminalPanel\}/.test(appSource)
|
||||
/onPointerEnter=\{prefetchTerminal\}/.test(moreMenuSource)
|
||||
&& /onFocus=\{prefetchTerminal\}/.test(moreMenuSource)
|
||||
&& /void import\("\.\.\/components\/TerminalPanel"\)/.test(terminalLifecycleSource),
|
||||
true,
|
||||
"pointer and keyboard intent prefetch the terminal chunk before opening",
|
||||
"pointer and keyboard intent prefetch the terminal chunk before opening (More menu)",
|
||||
);
|
||||
eq(
|
||||
/useWarmTerminalPanel\(terminalPanelOpen, terminalResizing\)/.test(appSource)
|
||||
@@ -370,5 +375,45 @@ eq(
|
||||
"closing a warm terminal removes portaled selection controls",
|
||||
);
|
||||
|
||||
// C1: the chat pane keeps its 400px floor no matter how wide the dock is
|
||||
// dragged — the dock's available width is viewport minus sidebar minus the
|
||||
// 400px chat minimum minus the resizer, so chat can never be squeezed below it.
|
||||
const chatFloorDock = availableWorkspacePanelWidth({
|
||||
viewportWidth: 1000,
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: SIDEBAR_WIDTH,
|
||||
chatMinWidth: CHAT_MIN_WIDTH,
|
||||
resizerWidth: RESIZER_WIDTH,
|
||||
});
|
||||
eq(
|
||||
chatFloorDock + SIDEBAR_WIDTH + CHAT_MIN_WIDTH + RESIZER_WIDTH <= 1000,
|
||||
true,
|
||||
"C1: dock width never consumes the chat 400px floor (chat stays readable)",
|
||||
);
|
||||
// Sanity: with a wide viewport the dock gets more room, but the chat floor is
|
||||
// still reserved — chat is never the thing that shrinks.
|
||||
const wideDock = availableWorkspacePanelWidth({
|
||||
viewportWidth: 1600,
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: SIDEBAR_WIDTH,
|
||||
chatMinWidth: CHAT_MIN_WIDTH,
|
||||
resizerWidth: RESIZER_WIDTH,
|
||||
});
|
||||
eq(wideDock > chatFloorDock, true, "C1: wider viewport gives the dock more room, chat floor untouched");
|
||||
|
||||
// C3: switching dock tabs (context/files/changed) must never resize the dock —
|
||||
// the preferred width is a single source (rightDockTreeWidth), not a
|
||||
// detail-dependent ternary that would jump the sidebar per tab.
|
||||
eq(
|
||||
/const preferredWorkspacePanelWidth = rightDockTreeWidth;/.test(appSource),
|
||||
true,
|
||||
"C3: preferredWorkspacePanelWidth is the single tree width (no detail ternary)",
|
||||
);
|
||||
eq(
|
||||
/const preferredWorkspacePanelWidth = rightDockDetailActive \? rightDockPreviewWidth : rightDockTreeWidth;/.test(appSource),
|
||||
false,
|
||||
"C3: no preview-width dual system that would resize the sidebar on tab switch",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,65 @@
|
||||
// Run: tsx src/__tests__/workspace-panel-open-migration.test.ts
|
||||
// zk-ge C14 兼容性:项目级 dock 开合 key 缺失时回退旧全局 key,升级不翻转用户选择
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function installLocalStorage(seed: Record<string, string>) {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost/" });
|
||||
globalThis.window = dom.window;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
for (const [key, value] of Object.entries(seed)) {
|
||||
dom.window.localStorage.setItem(key, value);
|
||||
}
|
||||
return dom;
|
||||
}
|
||||
|
||||
// 动态 import(依赖 window/localStorage 已就绪)
|
||||
async function loadModule() {
|
||||
return await import("../store/layout");
|
||||
}
|
||||
|
||||
console.log("\nworkspace dock-open legacy key migration (CLAIM.PERSIST.014)");
|
||||
|
||||
{
|
||||
// 场景 1:项目 key 缺失 + 旧全局 key = 关闭("0") → 首次打开项目应保持关闭
|
||||
installLocalStorage({ "reasonix.workspacePanel.open": "0" });
|
||||
const { loadWorkspacePanelOpen } = await loadModule();
|
||||
ok(loadWorkspacePanelOpen("project-a") === false, "project key missing + legacy global closed => stays closed");
|
||||
}
|
||||
|
||||
{
|
||||
// 场景 2:项目 key 缺失 + 旧全局 key = 打开("1") → 首次打开项目应打开
|
||||
installLocalStorage({ "reasonix.workspacePanel.open": "1" });
|
||||
const { loadWorkspacePanelOpen } = await loadModule();
|
||||
ok(loadWorkspacePanelOpen("project-b") === true, "project key missing + legacy global open => stays open");
|
||||
}
|
||||
|
||||
{
|
||||
// 场景 3:项目 key 存在(优先于旧全局 key)
|
||||
installLocalStorage({ "reasonix.workspacePanel.open": "0", "reasonix.workspacePanel.open.project-c": "1" });
|
||||
const { loadWorkspacePanelOpen } = await loadModule();
|
||||
ok(loadWorkspacePanelOpen("project-c") === true, "project key present wins over legacy global");
|
||||
}
|
||||
|
||||
{
|
||||
// 场景 4:项目 key 和旧全局 key 都缺失 → 默认打开
|
||||
installLocalStorage({});
|
||||
const { loadWorkspacePanelOpen } = await loadModule();
|
||||
ok(loadWorkspacePanelOpen("project-d") === true, "neither key present => default open");
|
||||
}
|
||||
|
||||
console.log(`\nworkspace dock-open migration: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,181 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { GitCommitView, WireCompletionSummary, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
|
||||
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".css")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
export function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
export async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
dom.window.ResizeObserver = TestResizeObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
|
||||
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
|
||||
configurable: true,
|
||||
get: () => 320,
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: function offsetHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: function getBoundingClientRect(this: HTMLElement) {
|
||||
const width = 320;
|
||||
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
width,
|
||||
height,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
},
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
export async function renderWorkspace(
|
||||
changes: WorkspaceChangesView,
|
||||
options: { creationMode?: boolean; history?: GitCommitView[]; detail?: WorkspaceChangeDetailView; completionSummary?: WireCompletionSummary } = {},
|
||||
) {
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => options.history ?? [],
|
||||
WorkspaceChanges: async () => changes,
|
||||
WorkspaceChangeDetail: async () => options.detail ?? {},
|
||||
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel
|
||||
open
|
||||
tabId="tab-a"
|
||||
cwd="/repo"
|
||||
maximized={false}
|
||||
initialViewMode="changed"
|
||||
creationMode={options.creationMode}
|
||||
completionSummary={options.completionSummary}
|
||||
onClose={() => {}}
|
||||
onToggleMaximized={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
|
||||
return { dom, root };
|
||||
}
|
||||
|
||||
export async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
WorkspaceChangeDetail: async () => ({}),
|
||||
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
|
||||
...methods,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
|
||||
open: true,
|
||||
tabId: "tab-a",
|
||||
cwd: "/repo",
|
||||
maximized: false,
|
||||
initialViewMode: "files",
|
||||
onClose: () => {},
|
||||
onToggleMaximized: () => {},
|
||||
...props,
|
||||
};
|
||||
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel {...currentProps} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
};
|
||||
await rerender();
|
||||
return { dom, root, rerender };
|
||||
}
|
||||
@@ -119,22 +119,22 @@ eq(
|
||||
);
|
||||
eq(
|
||||
finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden)", "grid-template-columns"),
|
||||
"var(--workspace-tree-rail-width) var(--workspace-tree-width) minmax(var(--workspace-preview-min-width), 1fr)",
|
||||
"split mode keeps a narrow tree toggle rail beside the file tree",
|
||||
"var(--workspace-tree-rail-width) minmax(var(--workspace-preview-min-width), 1fr) var(--workspace-tree-width)",
|
||||
"split mode keeps a narrow tree toggle rail beside the file tree (preview left, tree right per user preference)",
|
||||
);
|
||||
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-files", "grid-column"), "2", "file tree sits beside the rail");
|
||||
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-preview", "grid-column"), "3", "preview sits after rail and tree");
|
||||
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-files", "grid-column"), "3", "file tree sits at the far right after the preview");
|
||||
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-preview", "grid-column"), "2", "preview sits after the rail, before the tree");
|
||||
eq(
|
||||
finalDeclaration(".workspace-panel--with-tree-rail .workspace-tree-resizer", "left"),
|
||||
"calc(var(--workspace-tree-rail-width) + var(--workspace-tree-width) - 4px)",
|
||||
"tree resizer accounts for the persistent rail",
|
||||
"calc(100% - var(--workspace-tree-width) - 4px)",
|
||||
"tree resizer sits at the tree's left edge (tree is on the right)",
|
||||
);
|
||||
eq(
|
||||
finalDeclaration(".workspace-panel--tree-hidden", "grid-template-columns"),
|
||||
"var(--workspace-tree-rail-width) minmax(0, 1fr)",
|
||||
"preview-only mode keeps a narrow tree toggle rail",
|
||||
"minmax(0, 1fr)",
|
||||
"preview-only mode drops the tree rail",
|
||||
);
|
||||
eq(finalDeclaration(".workspace-panel--tree-hidden .workspace-preview", "grid-column"), "2", "preview sits beside the rail");
|
||||
eq(finalDeclaration(".workspace-panel--tree-hidden .workspace-preview", "grid-column"), "1", "preview fills the panel when the tree is hidden");
|
||||
eq(
|
||||
/const filePreviewLimit = 2 \* 1024 \* 1024/.test(appGo),
|
||||
true,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Run: tsx src/__tests__/workspace-resize-interaction.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq<T>(actual: T, expected: T, label: string) {
|
||||
if (Object.is(actual, expected)) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${String(expected)}, got ${String(actual)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
dom.window.ResizeObserver = TestResizeObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 800 });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: function offsetHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: function getBoundingClientRect(this: HTMLElement) {
|
||||
const width = 800;
|
||||
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
return { x: 100, y: 0, top: 0, left: 100, right: 900, bottom: height, width, height, toJSON: () => ({}) } as DOMRect;
|
||||
},
|
||||
});
|
||||
|
||||
console.log("\nworkspace right-side tree resize interaction");
|
||||
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async (_tabId, dir) => dir === "" ? [{ name: "app.ts", isDir: false }] : [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
WorkspaceChangeDetail: async () => ({}),
|
||||
ReadFileForTab: async (_tabId, path) => ({ path, body: "const value = 1;", size: 16, truncated: false, binary: false }),
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel
|
||||
open
|
||||
tabId="resize-tab"
|
||||
cwd="/repo"
|
||||
maximized={false}
|
||||
panelWidth={800}
|
||||
initialViewMode="files"
|
||||
onClose={() => {}}
|
||||
onToggleMaximized={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await waitFor("workspace file", () => document.querySelector('[data-workspace-path="app.ts"]') !== null);
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>('[data-workspace-path="app.ts"]')?.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("tree separator", () => document.querySelector(".workspace-tree-resizer") !== null);
|
||||
|
||||
const separator = document.querySelector<HTMLButtonElement>(".workspace-tree-resizer");
|
||||
if (!separator) throw new Error("missing tree separator");
|
||||
const initialWidth = Number(separator.getAttribute("aria-valuenow"));
|
||||
|
||||
await act(async () => {
|
||||
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowRight" }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(Number(separator.getAttribute("aria-valuenow")), initialWidth - 16, "ArrowRight moves the separator right and narrows the right-side tree");
|
||||
|
||||
await act(async () => {
|
||||
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowLeft" }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(Number(separator.getAttribute("aria-valuenow")), initialWidth, "ArrowLeft moves the separator left and widens the right-side tree");
|
||||
|
||||
await act(async () => {
|
||||
separator.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, cancelable: true, clientX: 600 }));
|
||||
window.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientX: 650 }));
|
||||
window.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientX: 650 }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(Number(separator.getAttribute("aria-valuenow")), 250, "pointer position measures tree width from the panel's right edge");
|
||||
|
||||
await act(async () => {
|
||||
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "End" }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(Number(separator.getAttribute("aria-valuenow")), 616, "End preserves minimum space for the rail and preview");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,91 @@
|
||||
// Run: tsx src/__tests__/workspace-selection-isolation.test.tsx
|
||||
|
||||
import { act } from "react";
|
||||
import { flushPromises, renderFilesWorkspace, waitFor } from "./workspace-panel-test-harness";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nworkspace selection isolation");
|
||||
|
||||
const changeDetailPaths: string[] = [];
|
||||
const { dom, root, rerender } = await renderFilesWorkspace({
|
||||
ListDirForTab: async (_tabId, dir) => dir === "" ? [{ name: "notes.txt", isDir: false }] : [],
|
||||
WorkspaceChanges: async () => ({
|
||||
files: [{ path: "src/changed.ts", sources: ["git"], gitStatus: "M" }],
|
||||
gitAvailable: true,
|
||||
}),
|
||||
WorkspaceChangeDetail: async (_tabId, path) => {
|
||||
changeDetailPaths.push(path);
|
||||
return { source: "git", diff: "changed diff" };
|
||||
},
|
||||
ReadFileForTab: async (_tabId, path) => ({ path, body: "file preview", size: 12, truncated: false, binary: false }),
|
||||
});
|
||||
|
||||
await waitFor("files view entry", () => document.body.textContent?.includes("notes.txt") === true);
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>('[data-workspace-path="notes.txt"]')
|
||||
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("selected file preview", () => document.body.textContent?.includes("file preview") === true);
|
||||
|
||||
await rerender({ initialViewMode: "changed" });
|
||||
await waitFor("changes overview", () => document.body.textContent?.includes("changed.ts") === true);
|
||||
ok(changeDetailPaths.length === 0, "entering Changes does not request diff detail for the Files selection");
|
||||
ok(document.querySelector(".workspace-panel--changed-overview") !== null, "entering Changes opens its overview instead of leaking the Files selection");
|
||||
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>(".workspace-change")
|
||||
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("selected change detail", () => changeDetailPaths.includes("src/changed.ts"));
|
||||
|
||||
await rerender({ initialViewMode: "files" });
|
||||
await waitFor("restored file preview", () => document.body.textContent?.includes("file preview") === true);
|
||||
ok(changeDetailPaths.every((path) => path === "src/changed.ts"), "Changes requests only its own selected path");
|
||||
ok(document.body.textContent?.includes("notes.txt") === true, "returning to Files restores the prior file selection after viewing a change");
|
||||
|
||||
await rerender({
|
||||
initialViewMode: "changed",
|
||||
changeListRequest: {
|
||||
id: 1,
|
||||
changes: [{ key: "scoped-change", path: "src/changed.ts", meta: "M", time: "", detail: "changed" }],
|
||||
},
|
||||
});
|
||||
await waitFor("scoped Changes list", () => document.body.textContent?.includes("Session changes") === true);
|
||||
await rerender({ initialViewMode: "files", changeListRequest: null });
|
||||
await waitFor("file preview after scoped Changes", () => document.body.textContent?.includes("file preview") === true);
|
||||
ok(document.body.textContent?.includes("notes.txt") === true, "scoped Changes requests preserve the Files preview state");
|
||||
|
||||
await rerender({ initialViewMode: "changed" });
|
||||
await waitFor("change before scope switch", () => document.body.textContent?.includes("changed.ts") === true);
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>(".workspace-change")
|
||||
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("change selected before scope switch", () => changeDetailPaths.includes("src/changed.ts"));
|
||||
const callsBeforeScopeSwitch = changeDetailPaths.length;
|
||||
await rerender({ workspaceScopeKey: "other-session" });
|
||||
await waitFor("new scope Changes overview", () => document.querySelector(".workspace-panel--changed-overview") !== null);
|
||||
ok(changeDetailPaths.length === callsBeforeScopeSwitch, "workspace scope changes do not request the previous scope's selected change");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -81,6 +81,34 @@ eq(
|
||||
"tree resize pointer clamps against the preview minimum after reserving the rail",
|
||||
);
|
||||
|
||||
eq(
|
||||
workspaceSplitTreeWidthFromPointer({
|
||||
clientX: 650,
|
||||
panelLeft: 100,
|
||||
panelWidth: 800,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
treeOnRight: true,
|
||||
}),
|
||||
250,
|
||||
"right-side tree width is measured from the pointer to the panel's right edge",
|
||||
);
|
||||
|
||||
eq(
|
||||
workspaceSplitTreeWidthFromPointer({
|
||||
clientX: 100,
|
||||
panelLeft: 100,
|
||||
panelWidth: 800,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
treeOnRight: true,
|
||||
}),
|
||||
616,
|
||||
"right-side tree maximum keeps space for the rail and preview",
|
||||
);
|
||||
|
||||
eq(
|
||||
resolveWorkspaceSplitTreeWidth({
|
||||
mode: "even",
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// Run: tsx src/__tests__/workspace-tree-scroll-restore.test.tsx
|
||||
// zk-ge CLAIM.TREE.008: 切换 dock 标签(组件 unmount/remount)后文件树滚动位置恢复
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
dom.window.ResizeObserver = TestResizeObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 320 });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
|
||||
configurable: true,
|
||||
get: function clientHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 300 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: function scrollHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 40 * 24 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: function offsetHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: function getBoundingClientRect(this: HTMLElement) {
|
||||
const width = 320;
|
||||
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
return { x: 0, y: 0, top: 0, left: 0, right: width, bottom: height, width, height, toJSON: () => ({}) } as DOMRect;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollTo", {
|
||||
configurable: true,
|
||||
value: function scrollTo(this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const requested = typeof options === "number" ? options : options?.top ?? 0;
|
||||
const maximum = Math.max(0, this.scrollHeight - this.clientHeight);
|
||||
this.scrollTop = Math.max(0, Math.min(maximum, requested));
|
||||
},
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
const MEMORY_KEY = "scroll-test\u0000/repo";
|
||||
|
||||
function renderPanel(root: Root) {
|
||||
return act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel
|
||||
open
|
||||
tabId="scroll-tab"
|
||||
cwd="/repo"
|
||||
workspaceMemoryKey={MEMORY_KEY}
|
||||
maximized={false}
|
||||
initialViewMode="files"
|
||||
onClose={() => {}}
|
||||
onToggleMaximized={() => {}}
|
||||
onOpenInTerminal={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
}
|
||||
|
||||
console.log("\nworkspace tree scroll restore (CLAIM.TREE.008)");
|
||||
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
const dom = installDom();
|
||||
dom.window.localStorage.clear();
|
||||
const RESTORED_SCROLL_TOP = 200;
|
||||
dom.window.localStorage.setItem("reasonix.workspaceState.v2", JSON.stringify({
|
||||
version: 2,
|
||||
projects: [{
|
||||
key: MEMORY_KEY,
|
||||
state: {
|
||||
openDirs: [""],
|
||||
scrollTop: RESTORED_SCROLL_TOP,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}],
|
||||
}));
|
||||
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async (_tabId, dir) => {
|
||||
if (dir === "") {
|
||||
return Array.from({ length: 40 }, (_, i) => ({ name: `file-${i}.ts`, isDir: false }));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
SearchFileRefsForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
WorkspaceChangeDetail: async () => ({}),
|
||||
ReadFileForTab: async (_tabId, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
|
||||
ResolveWorkspacePathForTab: async (_tabId, path) => `/repo/${path}`,
|
||||
RevealWorkspacePathForTab: async () => {},
|
||||
OpenWorkspacePathForTab: async () => {},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
|
||||
// Phase 1: mount from a persisted deep offset and verify the virtualizer writes
|
||||
// that offset into a genuinely scrollable DOM fixture.
|
||||
await renderPanel(root);
|
||||
await waitFor("workspace rows render", () => document.querySelectorAll(".workspace-tree__row").length > 0);
|
||||
|
||||
const treeEl = document.querySelector<HTMLElement>(".workspace-tree");
|
||||
if (!treeEl) throw new Error("missing workspace-tree element");
|
||||
ok(treeEl.scrollHeight > treeEl.clientHeight, "fixture has a real scrollable extent");
|
||||
await waitFor("persisted tree offset restore", () => treeEl.scrollTop === RESTORED_SCROLL_TOP);
|
||||
ok(treeEl.scrollTop === RESTORED_SCROLL_TOP, "initial mount restores the persisted DOM scroll offset");
|
||||
|
||||
// Scroll position should have been persisted to the upstream per-project
|
||||
// workspace state envelope (reasonix.workspaceState.v2).
|
||||
function persistedScrollTop(): number | null {
|
||||
const raw = dom.window.localStorage.getItem("reasonix.workspaceState.v2");
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { projects?: Array<{ key: string; state: { scrollTop?: number } }> };
|
||||
const project = parsed.projects?.find((entry) => entry.key === MEMORY_KEY);
|
||||
return typeof project?.state?.scrollTop === "number" ? project.state.scrollTop : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ok(persistedScrollTop() === RESTORED_SCROLL_TOP, "restoration preserves the persisted localStorage offset");
|
||||
|
||||
// Phase 2: unmount (simulates switching from 文件 to 概览 dock tab)
|
||||
await act(async () => {
|
||||
root.render(<></>);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
// Phase 3: remount (simulates switching back to 文件)
|
||||
await renderPanel(root);
|
||||
await waitFor("workspace rows re-render", () => document.querySelectorAll(".workspace-tree__row").length > 0);
|
||||
|
||||
const treeEl2 = document.querySelector<HTMLElement>(".workspace-tree");
|
||||
if (!treeEl2) throw new Error("missing workspace-tree element after remount");
|
||||
|
||||
// The persisted value must survive the remount — verify the workspace memory
|
||||
// still holds it (this isolates persistence loss from virtualizer restore).
|
||||
ok(persistedScrollTop() === RESTORED_SCROLL_TOP, "persisted scroll offset survives remount in localStorage");
|
||||
|
||||
// Remount must re-render the tree rows (otherwise the restore effect's
|
||||
// treeRows.length guard never passes).
|
||||
const rowsAfterRemount = document.querySelectorAll(".workspace-tree__row").length;
|
||||
ok(rowsAfterRemount > 0, `tree rows re-render after remount (got ${rowsAfterRemount})`);
|
||||
|
||||
await waitFor("remounted tree DOM restore", () => treeEl2.scrollTop === RESTORED_SCROLL_TOP);
|
||||
ok(treeEl2.scrollTop === RESTORED_SCROLL_TOP, "dock-tab remount restores the actual DOM scroll offset");
|
||||
|
||||
// Phase 4: switch to the "changed" view via the tab button, then back to
|
||||
// "files". The pending scroll restore must be reset on viewMode change — the
|
||||
// saved offset belongs to the previous mode's tree and re-applying it to a
|
||||
// shorter list (files -> changed) would scroll past the end and leave a blank
|
||||
// band at the top. We assert the panel still renders rows after the round
|
||||
// trip; the reset itself is what makes that render coherent.
|
||||
const tabs = document.querySelectorAll(".workspace-files__tab");
|
||||
ok(tabs.length >= 2, "files and changed view tabs render in the fixture");
|
||||
if (tabs.length >= 2) {
|
||||
await act(async () => {
|
||||
(tabs[1] as HTMLElement).click();
|
||||
await flushTimers();
|
||||
});
|
||||
await act(async () => {
|
||||
(tabs[0] as HTMLElement).click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("files rows re-render after view-mode round trip", () => document.querySelectorAll(".workspace-tree__row").length > 0);
|
||||
ok(document.querySelectorAll(".workspace-tree__row").length > 0, "tree rows re-render after files<->changed view switch");
|
||||
}
|
||||
|
||||
console.log(`\nworkspace tree scroll restore: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -19,7 +19,6 @@ interface AppChromeProps {
|
||||
sidebarToggleTitle: string;
|
||||
workspacePanelMaximized: boolean;
|
||||
workspacePanelRenderable: boolean;
|
||||
workspaceTogglePressed: boolean;
|
||||
workspacePanelLabel: string;
|
||||
onToggleSidebar: () => void;
|
||||
onToggleWorkspacePanel: () => void;
|
||||
@@ -45,7 +44,6 @@ export function AppChrome({
|
||||
sidebarToggleTitle,
|
||||
workspacePanelMaximized,
|
||||
workspacePanelRenderable,
|
||||
workspaceTogglePressed,
|
||||
workspacePanelLabel,
|
||||
onToggleSidebar,
|
||||
onToggleWorkspacePanel,
|
||||
@@ -129,7 +127,6 @@ export function AppChrome({
|
||||
className={[
|
||||
"app-chrome__tools",
|
||||
"app-chrome__tools--fixed",
|
||||
workspaceTogglePressed ? "app-chrome__tools--workspace-pressed" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
aria-label={t("tabBar.commandSearch")}
|
||||
>
|
||||
@@ -156,7 +153,6 @@ export function AppChrome({
|
||||
<div
|
||||
className={[
|
||||
"app-chrome__tools",
|
||||
workspaceTogglePressed ? "app-chrome__tools--workspace-pressed" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
aria-label={t("tabBar.commandSearch")}
|
||||
>
|
||||
@@ -183,7 +179,6 @@ export function AppChrome({
|
||||
"app-chrome__panel-toggle",
|
||||
"app-chrome__panel-toggle--right",
|
||||
workspacePanelRenderable ? "app-chrome__panel-toggle--active" : "",
|
||||
workspaceTogglePressed ? "app-chrome__panel-toggle--pressed" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
type="button"
|
||||
onClick={onToggleWorkspacePanel}
|
||||
|
||||
@@ -20,6 +20,9 @@ export function PromptShelf({
|
||||
role = "dialog",
|
||||
decision = false,
|
||||
actionsRole = "listbox",
|
||||
cardCollapsible = false,
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
className?: string;
|
||||
cardClassName?: string;
|
||||
@@ -45,6 +48,11 @@ export function PromptShelf({
|
||||
// Select-then-confirm surfaces use listbox. Immediate actions are a group of
|
||||
// buttons so assistive technology does not announce them as pending choices.
|
||||
actionsRole?: "listbox" | "group";
|
||||
// Whole-card click-to-collapse (used by the todo shelf): clicking anywhere
|
||||
// on the card toggles collapsed; interactive children stop propagation.
|
||||
cardCollapsible?: boolean;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -59,22 +67,40 @@ export function PromptShelf({
|
||||
>
|
||||
<div
|
||||
ref={barRef}
|
||||
className={["prompt-shelf__card", cardClassName ?? ""].filter(Boolean).join(" ")}
|
||||
className={[
|
||||
"prompt-shelf__card",
|
||||
cardCollapsible ? "prompt-shelf__card--collapsible" : "",
|
||||
cardCollapsible && collapsed ? "prompt-shelf__card--collapsed" : "",
|
||||
cardClassName ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role={role}
|
||||
aria-modal={role === "dialog" ? "false" : undefined}
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
tabIndex={cardCollapsible ? 0 : -1}
|
||||
onClick={cardCollapsible ? onToggleCollapse : undefined}
|
||||
onKeyDown={cardCollapsible && onToggleCollapse ? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onToggleCollapse();
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
<div className="prompt-shelf__content">
|
||||
<div className="prompt-shelf__header">
|
||||
<div className="prompt-shelf__copy">
|
||||
<div id={titleId} className="prompt-shelf__title">
|
||||
<span className="prompt-shelf__heading">{title}</span>
|
||||
{meta && <span className="prompt-shelf__meta">{meta}</span>}
|
||||
{badges && <span className="prompt-shelf__badges">{badges}</span>}
|
||||
</div>
|
||||
{meta && <div className="prompt-shelf__meta">{meta}</div>}
|
||||
</div>
|
||||
{headerActions && <div className="prompt-shelf__header-actions">{headerActions}</div>}
|
||||
{headerActions && (
|
||||
<div className="prompt-shelf__header-actions" onClick={(event) => event.stopPropagation()}>
|
||||
{headerActions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{crumbs}
|
||||
{children && <div className="prompt-shelf__body">{children}</div>}
|
||||
|
||||
@@ -85,23 +85,20 @@ export function TodoPanel({
|
||||
badges={<PromptBadge>{done}/{todos.length}</PromptBadge>}
|
||||
meta={summary}
|
||||
role="region"
|
||||
cardClassName="prompt-shelf--todo"
|
||||
cardCollapsible
|
||||
collapsed={!open}
|
||||
onToggleCollapse={() => setOpen((value) => {
|
||||
const next = !value;
|
||||
saveOpenState(stateKey, next);
|
||||
return next;
|
||||
})}
|
||||
headerActions={
|
||||
<>
|
||||
<PromptHeaderAction
|
||||
onClick={() => setOpen((value) => {
|
||||
const next = !value;
|
||||
saveOpenState(stateKey, next);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
{open ? t("common.collapse") : t("common.expand")}
|
||||
allDone && (
|
||||
<PromptHeaderAction onClick={onDismiss}>
|
||||
{t("common.close")}
|
||||
</PromptHeaderAction>
|
||||
{allDone && (
|
||||
<PromptHeaderAction onClick={onDismiss}>
|
||||
{t("common.close")}
|
||||
</PromptHeaderAction>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{open && (
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import type { TopicbarMoreMenuContentProps } from "./TopicbarMoreMenuContent";
|
||||
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
const loadMenuContent = () => import("./TopicbarMoreMenuContent");
|
||||
const TopicbarMoreMenuContent = lazy(async () => ({
|
||||
default: (await loadMenuContent()).TopicbarMoreMenuContent,
|
||||
}));
|
||||
|
||||
type TopicbarMoreMenuProps = Omit<TopicbarMoreMenuContentProps, "copied" | "initialFocus" | "onClose" | "onCopied">;
|
||||
|
||||
/** Keeps the topic bar compact while loading overflow actions only on demand. */
|
||||
export function TopicbarMoreMenu(props: TopicbarMoreMenuProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedTimerRef = useRef<number | null>(null);
|
||||
|
||||
const closeMenu = (restoreFocus: boolean) => {
|
||||
setMenuOpen(false);
|
||||
if (restoreFocus) triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) closeMenu(false);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeMenu(true);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (copiedTimerRef.current != null) window.clearTimeout(copiedTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const markCopied = () => {
|
||||
setCopied(true);
|
||||
if (copiedTimerRef.current != null) window.clearTimeout(copiedTimerRef.current);
|
||||
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1200);
|
||||
};
|
||||
|
||||
const openFromKeyboard = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
event.preventDefault();
|
||||
setInitialFocus(event.key === "ArrowUp" ? "last" : "first");
|
||||
setMenuOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`topicbar__more${menuOpen ? " topicbar__more--open" : ""}`}>
|
||||
<Tooltip label={t("topicBar.more")}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
|
||||
type="button"
|
||||
aria-label={t("topicBar.more")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onPointerEnter={() => { void loadMenuContent(); }}
|
||||
onFocus={() => { void loadMenuContent(); }}
|
||||
onKeyDown={openFromKeyboard}
|
||||
onClick={() => {
|
||||
setInitialFocus("first");
|
||||
setMenuOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{menuOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<TopicbarMoreMenuContent
|
||||
{...props}
|
||||
copied={copied}
|
||||
initialFocus={initialFocus}
|
||||
onClose={closeMenu}
|
||||
onCopied={markCopied}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.topicbar__more-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: var(--z-topicbar-menu);
|
||||
min-width: 168px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-elev);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.36);
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
.topicbar__menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 9px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.topicbar__menu-item:hover,
|
||||
.topicbar__menu-item:focus-visible {
|
||||
color: var(--fg);
|
||||
background: var(--button-bg);
|
||||
}
|
||||
|
||||
.topicbar__menu-item:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.topicbar__menu-item--active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.topicbar__more-export {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topicbar__more-export-menu {
|
||||
position: absolute;
|
||||
/* The primary menu sits at the far right, so the submenu opens left. */
|
||||
right: calc(100% - 8px);
|
||||
top: -5px;
|
||||
z-index: var(--z-topicbar-menu);
|
||||
min-width: 168px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-elev);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.36);
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
.topicbar__more-export-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 9px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.topicbar__more-export-menu button:hover,
|
||||
.topicbar__more-export-menu button:focus-visible {
|
||||
color: var(--fg);
|
||||
background: var(--button-bg);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
FileDown,
|
||||
FileImage,
|
||||
FileJson,
|
||||
FileText,
|
||||
GitBranch,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
import "./TopicbarMoreMenuContent.css";
|
||||
|
||||
import { writeClipboardText } from "../lib/clipboard";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
export interface TopicbarMoreMenuContentProps {
|
||||
sessionHasContent: boolean;
|
||||
getSessionMarkdown: () => string | Promise<string>;
|
||||
exportSession: (format: "markdown" | "json" | "pdf" | "image") => void;
|
||||
openChangedDock: () => void;
|
||||
toggleTerminal: () => void;
|
||||
prefetchTerminal?: () => void;
|
||||
openSessionSummary: () => void;
|
||||
tasksOpen: boolean;
|
||||
copied: boolean;
|
||||
initialFocus: "first" | "last";
|
||||
onClose: (restoreFocus: boolean) => void;
|
||||
onCopied: () => void;
|
||||
}
|
||||
|
||||
function enabledItems(menu: HTMLElement): HTMLButtonElement[] {
|
||||
return Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
|
||||
.filter((item) => !item.disabled && item.closest('[role="menu"]') === menu);
|
||||
}
|
||||
|
||||
function moveMenuFocus(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const items = enabledItems(event.currentTarget);
|
||||
if (items.length === 0) return;
|
||||
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const next = event.key === "Home" ? 0
|
||||
: event.key === "End" ? items.length - 1
|
||||
: event.key === "ArrowDown" ? (current + 1 + items.length) % items.length
|
||||
: (current - 1 + items.length) % items.length;
|
||||
items[next]?.focus();
|
||||
}
|
||||
|
||||
export function TopicbarMoreMenuContent({
|
||||
sessionHasContent,
|
||||
getSessionMarkdown,
|
||||
exportSession,
|
||||
openChangedDock,
|
||||
toggleTerminal,
|
||||
prefetchTerminal,
|
||||
openSessionSummary,
|
||||
tasksOpen,
|
||||
copied,
|
||||
initialFocus,
|
||||
onClose,
|
||||
onCopied,
|
||||
}: TopicbarMoreMenuContentProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const exportTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const exportMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const items = menuRef.current ? enabledItems(menuRef.current) : [];
|
||||
(initialFocus === "last" ? items[items.length - 1] : items[0])?.focus();
|
||||
}, [initialFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!exportOpen || !exportMenuRef.current) return;
|
||||
enabledItems(exportMenuRef.current)[0]?.focus();
|
||||
}, [exportOpen]);
|
||||
|
||||
const closeAndRun = (action: () => void) => {
|
||||
onClose(true);
|
||||
action();
|
||||
};
|
||||
|
||||
const copySession = async () => {
|
||||
try {
|
||||
await writeClipboardText(await getSessionMarkdown());
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
onCopied();
|
||||
};
|
||||
|
||||
const onMainMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose(true);
|
||||
return;
|
||||
}
|
||||
moveMenuFocus(event);
|
||||
};
|
||||
|
||||
const onExportMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setExportOpen(false);
|
||||
exportTriggerRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
moveMenuFocus(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="topicbar__more-menu" role="menu" aria-label={t("topicBar.more")} onKeyDown={onMainMenuKeyDown}>
|
||||
<button tabIndex={-1} className="topicbar__menu-item" type="button" role="menuitem" onClick={() => closeAndRun(() => { void copySession(); })}>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
<span>{copied ? t("msg.copied") : t("topicBar.copyAll")}</span>
|
||||
</button>
|
||||
<div className={`topicbar__more-export${exportOpen ? " topicbar__more-export--open" : ""}`}>
|
||||
<button
|
||||
ref={exportTriggerRef}
|
||||
tabIndex={-1}
|
||||
className="topicbar__menu-item"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={exportOpen}
|
||||
disabled={!sessionHasContent}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ArrowRight") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setExportOpen(true);
|
||||
}}
|
||||
onClick={() => setExportOpen((open) => !open)}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span>{t("topicBar.export")}</span>
|
||||
</button>
|
||||
{exportOpen && (
|
||||
<div ref={exportMenuRef} className="topicbar__more-export-menu" role="menu" aria-label={t("topicBar.export")} onKeyDown={onExportMenuKeyDown}>
|
||||
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("markdown"))}>
|
||||
<FileText size={13} />
|
||||
<span>{t("topicBar.exportMarkdown")}</span>
|
||||
</button>
|
||||
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("json"))}>
|
||||
<FileJson size={13} />
|
||||
<span>{t("topicBar.exportJson")}</span>
|
||||
</button>
|
||||
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("pdf"))}>
|
||||
<FileDown size={13} />
|
||||
<span>{t("topicBar.exportPdf")}</span>
|
||||
</button>
|
||||
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("image"))}>
|
||||
<FileImage size={13} />
|
||||
<span>{t("topicBar.exportImage")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button tabIndex={-1} className="topicbar__menu-item" type="button" role="menuitem" onClick={() => closeAndRun(openChangedDock)}>
|
||||
<GitBranch size={14} />
|
||||
<span>{t("workspace.changedTab")}</span>
|
||||
</button>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="topicbar__menu-item"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onPointerEnter={prefetchTerminal}
|
||||
onFocus={prefetchTerminal}
|
||||
onClick={() => closeAndRun(toggleTerminal)}
|
||||
>
|
||||
<TerminalSquare size={14} />
|
||||
<span>{t("rightDock.terminal")}</span>
|
||||
</button>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className={`topicbar__menu-item${tasksOpen ? " topicbar__menu-item--active" : ""}`}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-pressed={tasksOpen}
|
||||
onClick={() => closeAndRun(openSessionSummary)}
|
||||
>
|
||||
<Activity size={14} />
|
||||
<span>{t("summary.session")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderTree,
|
||||
FolderX,
|
||||
GitBranch,
|
||||
@@ -39,8 +38,15 @@ import { closeWorkspacePreviewTab } from "../lib/workspacePreviewTabs";
|
||||
import { useWorkspaceRefresh } from "../lib/workspaceRefreshStore";
|
||||
import { useWorkspaceRefreshInvalidation, workspaceRefreshFallbackSequence } from "../lib/workspaceRefreshInvalidation";
|
||||
import { createWorkspaceRefreshScheduler } from "../lib/workspaceRefreshScheduler";
|
||||
import {
|
||||
beginKeyedResourceRequest,
|
||||
emptyKeyedResource,
|
||||
rejectKeyedResourceRequest,
|
||||
resolveKeyedResourceRequest,
|
||||
} from "../lib/keyedResource";
|
||||
import { shouldScrollWorkspaceTreeSelection } from "../lib/workspaceTreeReveal";
|
||||
import { mergeWorkspaceSearchResults } from "../lib/workspaceTreeSearch";
|
||||
import { useWorkspaceTreeScrollPersistence } from "../lib/useWorkspaceTreeScrollPersistence";
|
||||
import {
|
||||
readWorkspaceTreeMemory,
|
||||
rememberWorkspaceTreeOpenDirs,
|
||||
@@ -48,7 +54,6 @@ import {
|
||||
touchWorkspaceTreeVisit,
|
||||
workspaceTreeVisitId,
|
||||
} from "../lib/workspaceTreeMemory";
|
||||
import { useWorkspaceTreeScrollPersistence } from "../lib/useWorkspaceTreeScrollPersistence";
|
||||
import { loadLayoutSize, loadOptionalLayoutSize } from "../lib/layoutPreferences";
|
||||
import {
|
||||
RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH,
|
||||
@@ -76,8 +81,9 @@ import { Markdown } from "./Markdown";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { AnchoredPopover } from "./AnchoredPopover";
|
||||
import { MarkdownImageTabContext } from "./MarkdownImageContext";
|
||||
import { WorkspaceFileIcon } from "./WorkspaceFileIcon";
|
||||
import { WorkspaceMediaPreview } from "./WorkspaceMediaPreview";
|
||||
import { buildWorkspacePathBreadcrumbs, WorkspacePathBreadcrumbs } from "./WorkspacePathBreadcrumbs";
|
||||
import { WorkspaceTreeRow, type WorkspaceTreeRowData } from "./WorkspaceTreeRow";
|
||||
import { WorkspaceTreeMenu } from "./WorkspaceTreeMenu";
|
||||
import { WORKSPACE_TURN_VERIFICATION_ID, WorkspaceTurnVerification } from "./WorkspaceTurnVerification";
|
||||
import { useWorkspaceChangesResource } from "../lib/useWorkspaceChangesResource";
|
||||
@@ -87,12 +93,6 @@ import {
|
||||
workspaceParentDirs as parentDirs, workspaceParentPath as parentPath,
|
||||
workspaceShortCwd as shortCwd, workspaceTopLevelDirPath as topLevelDirPath,
|
||||
} from "../lib/workspacePanelFormat";
|
||||
import {
|
||||
beginKeyedResourceRequest,
|
||||
emptyKeyedResource,
|
||||
rejectKeyedResourceRequest,
|
||||
resolveKeyedResourceRequest,
|
||||
} from "../lib/keyedResource";
|
||||
|
||||
const WORKSPACE_TREE_MIN_WIDTH = 140;
|
||||
const WORKSPACE_TREE_DEFAULT_WIDTH = 300;
|
||||
@@ -120,18 +120,6 @@ function clampWorkspaceTreeWidth(width: number, panelWidth?: number): number {
|
||||
});
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
key: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
entry: DirEntry;
|
||||
active: boolean;
|
||||
isOpen?: boolean;
|
||||
isSearch?: boolean;
|
||||
compactPaths?: string[];
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export function WorkspacePanel({
|
||||
open,
|
||||
tabId,
|
||||
@@ -204,6 +192,8 @@ export function WorkspacePanel({
|
||||
&& verificationRevealRequest.currentSummary === completionSummary ? verificationRevealRequest : null;
|
||||
const visibleCompletionSummary = activeVerificationRevealRequest?.summary ?? completionSummary;
|
||||
const workspaceScopeKey = workspaceScopeKeyProp ?? `${workspaceTabId}\u0000${cwd ?? ""}`;
|
||||
const lastWorkspaceScopeKeyRef = useRef(workspaceScopeKey);
|
||||
const scopeSwitchPendingRef = useRef(false);
|
||||
const workspaceMemoryKey = workspaceMemoryKeyProp ?? workspaceScopeKey;
|
||||
const workspaceMemoryVisitId = workspaceMemoryVisitIdProp ?? workspaceTreeVisitId(workspaceMemoryKey);
|
||||
const workspaceRefresh = useWorkspaceRefresh(workspaceTabId, workspaceScopeKey, open);
|
||||
@@ -231,6 +221,12 @@ export function WorkspacePanel({
|
||||
() => initialWorkspaceMemory?.selectedChangePath ?? null,
|
||||
);
|
||||
const [openTabs, setOpenTabs] = useState<string[]>([]);
|
||||
// Independent "recently opened" history: survives closing all preview tabs
|
||||
// (openTabs is the live preview state) and app restarts, so the recent-files
|
||||
// menu keeps the user's file history even after the previews are dismissed.
|
||||
const [recentPaths, setRecentPaths] = useState<string[]>(() =>
|
||||
(initialWorkspaceMemory?.recentPaths ?? []).slice(0, WORKSPACE_MAX_PREVIEW_TABS),
|
||||
);
|
||||
const [previewResource, setPreviewResource] = useState(() => emptyKeyedResource<FilePreview>());
|
||||
const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
|
||||
const selectedPath = viewMode === "changed" ? selectedChangePath : selectedFilePath;
|
||||
@@ -273,7 +269,6 @@ export function WorkspacePanel({
|
||||
const lastChangeListRequestIdRef = useRef<number | null>(null);
|
||||
const dismissedChangeListRequestIdRef = useRef<number | null>(null);
|
||||
const currentWorkspaceScopeKeyRef = useRef(workspaceScopeKey);
|
||||
const lastWorkspaceScopeKeyRef = useRef(workspaceScopeKey);
|
||||
const changeDetailRequestIdRef = useRef(0);
|
||||
const gitHistoryRequestIdRef = useRef(0);
|
||||
const previewRequestIdRef = useRef(0);
|
||||
@@ -371,6 +366,11 @@ export function WorkspacePanel({
|
||||
rememberWorkspaceTreeState(workspaceMemoryKey, { treeWidth, treeWidthMode });
|
||||
}, [treeWidth, treeWidthMode, workspaceMemoryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (memoryRestorePendingRef.current) return;
|
||||
rememberWorkspaceTreeState(workspaceMemoryKey, { recentPaths });
|
||||
}, [recentPaths, workspaceMemoryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (memoryRestorePendingRef.current || dockTreeWidth == null || dockPreviewWidth == null) return;
|
||||
rememberWorkspaceTreeState(workspaceMemoryKey, { dockTreeWidth, dockPreviewWidth });
|
||||
@@ -423,6 +423,10 @@ export function WorkspacePanel({
|
||||
setChangeDetailResource(emptyKeyedResource());
|
||||
return;
|
||||
}
|
||||
// Scope switch must not request the previous scope's selected change:
|
||||
// the selection is preserved for the original scope, but a request must
|
||||
// not fire for the just-switched scope before the scope effect settles.
|
||||
if (scopeSwitchPendingRef.current) return;
|
||||
const requestKey = `${requestScopeKey}\u0000change\u0000${requestPath}`;
|
||||
setChangeDetailResource((current) => beginKeyedResourceRequest(current, requestKey, requestId, workspaceRefresh.revisions.content));
|
||||
try {
|
||||
@@ -489,7 +493,11 @@ export function WorkspacePanel({
|
||||
setTreeWidth(initialWorkspaceSplitTreeWidth({
|
||||
panelWidth,
|
||||
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
|
||||
savedTreeWidth: null,
|
||||
// Preserve a user-resized (manual) tree width: only first-time
|
||||
// splits (no saved width yet) default to an even 50/50 division.
|
||||
// Reopening a file after closing its preview must keep the
|
||||
// remembered width, not snap back to the initial split.
|
||||
savedTreeWidth: treeWidthMode === "manual" ? treeWidth : null,
|
||||
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
|
||||
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
|
||||
}));
|
||||
@@ -508,6 +516,7 @@ export function WorkspacePanel({
|
||||
});
|
||||
setFilter("");
|
||||
setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
|
||||
setRecentPaths((paths) => [...paths.filter((p) => p !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
|
||||
const dirs = parentDirs(path);
|
||||
updateOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
@@ -541,6 +550,7 @@ export function WorkspacePanel({
|
||||
if (!open) return;
|
||||
if (lastWorkspaceScopeKeyRef.current === workspaceScopeKey) return;
|
||||
lastWorkspaceScopeKeyRef.current = workspaceScopeKey;
|
||||
scopeSwitchPendingRef.current = true;
|
||||
workingTreeRefreshSchedulerRef.current?.cancel();
|
||||
gitMetaRefreshSchedulerRef.current?.cancel();
|
||||
changeDetailRequestIdRef.current += 1;
|
||||
@@ -755,7 +765,11 @@ export function WorkspacePanel({
|
||||
if (!open) return;
|
||||
if (viewMode === "changed") {
|
||||
void loadWorkspaceChanges();
|
||||
if (selectedPath) void loadChangeDetail();
|
||||
// A scope switch just happened in this render batch: the previous
|
||||
// scope's selected change must not be re-requested, but the selection
|
||||
// itself is preserved for when the user returns to that scope.
|
||||
if (selectedPath && !scopeSwitchPendingRef.current) void loadChangeDetail();
|
||||
scopeSwitchPendingRef.current = false;
|
||||
if (commitHistoryOpen) void loadGitHistory();
|
||||
} else {
|
||||
changeDetailRequestIdRef.current += 1;
|
||||
@@ -980,14 +994,18 @@ export function WorkspacePanel({
|
||||
|
||||
const changedMode = viewMode === "changed";
|
||||
const currentFileName = selectedPath ? basename(selectedPath) : t("workspace.noFile");
|
||||
const currentFileDir = selectedPath ? parentPath(selectedPath) : "";
|
||||
const previewTitle = changedMode && !selectedPath
|
||||
? scopedChangeRows ? t("context.sessionChanges") : t("workspace.changedTab")
|
||||
: currentFileName;
|
||||
const previewSubtitle = changedMode && !selectedPath
|
||||
? scopedChangeRows ? t("context.changedMeta", { count: scopedChangeRows.length }) : shortCwd(cwd) || t("workspace.title")
|
||||
: currentFileDir;
|
||||
const recentFiles = useMemo(() => [...openTabs].reverse(), [openTabs]);
|
||||
const activeSelectedPath = selectedPath;
|
||||
// Changed overview shows just the project name (matching the file-preview
|
||||
// breadcrumb); hovering reveals the full absolute workspace root.
|
||||
const previewSubtitleCrumbs = changedMode && !selectedChangePath && !scopedChangeRows
|
||||
? (cwd ? [{ label: basename(cwd), full: cwd }] : [])
|
||||
: [];
|
||||
const previewFullPath = activeSelectedPath || "";
|
||||
const previewCrumbs = buildWorkspacePathBreadcrumbs(cwd, previewFullPath);
|
||||
const recentFiles = useMemo(() => [...recentPaths].reverse(), [recentPaths]);
|
||||
|
||||
const workspaceSearchFallbackSequence = workspaceRefreshFallbackSequence(workspaceRefresh);
|
||||
|
||||
@@ -1026,7 +1044,7 @@ export function WorkspacePanel({
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [entriesByDir, filter, scopedFilePaths, searchResults]);
|
||||
|
||||
const treeRows = useMemo<TreeRow[]>(() => {
|
||||
const treeRows = useMemo<WorkspaceTreeRowData[]>(() => {
|
||||
if (flattened) {
|
||||
return flattened.map(({ path, entry }) => ({
|
||||
key: path,
|
||||
@@ -1037,7 +1055,7 @@ export function WorkspacePanel({
|
||||
isSearch: true,
|
||||
}));
|
||||
}
|
||||
const acc: TreeRow[] = [];
|
||||
const acc: WorkspaceTreeRowData[] = [];
|
||||
const build = (dir: string, depth: number) => {
|
||||
const entries = entriesByDir[dir] ?? [];
|
||||
for (const entry of entries) {
|
||||
@@ -1097,10 +1115,48 @@ export function WorkspacePanel({
|
||||
overscan: 10,
|
||||
directDomUpdates: true,
|
||||
});
|
||||
// Restore the persisted scroll position once the tree has grown tall enough
|
||||
// to actually reach it. The tree loads asynchronously layer by layer: the
|
||||
// first render usually has only the top-level rows, so scrolling then would
|
||||
// clamp the saved offset to the (tiny) current scrollable range and lose the
|
||||
// position forever. Keep a pending target and retry on every tree change
|
||||
// until the tree's total height exceeds it, then scroll once.
|
||||
const pendingScrollRestoreRef = useRef<number | null>(null);
|
||||
// Reset the pending restore whenever the view mode changes: the saved offset
|
||||
// belongs to the previous mode's tree, and re-applying it to a shorter list
|
||||
// (e.g. files -> changed) scrolls past the end and leaves a blank band at
|
||||
// the top. Each mode restores its own offset on its own data.
|
||||
const previousViewModeRef = useRef(viewMode);
|
||||
if (previousViewModeRef.current !== viewMode) {
|
||||
previousViewModeRef.current = viewMode;
|
||||
pendingScrollRestoreRef.current = null;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!open || treeRows.length === 0) return;
|
||||
if (pendingScrollRestoreRef.current == null) {
|
||||
const saved = readWorkspaceTreeMemory(workspaceMemoryKey)?.scrollTop;
|
||||
if (saved == null || !Number.isFinite(saved) || saved <= 0) return;
|
||||
pendingScrollRestoreRef.current = saved;
|
||||
}
|
||||
const target = pendingScrollRestoreRef.current;
|
||||
// A saved offset is only reachable once the tree's total height exceeds
|
||||
// the offset by at least one viewport: the maximum scroll position is
|
||||
// totalSize - viewportHeight. Using totalSize < target would clear the
|
||||
// pending restore once content just barely exceeds the offset, but before
|
||||
// it provides enough scrollable space — the browser then clamps the scroll
|
||||
// and the deep-scroll restore is silently lost. Use the virtualizer's own
|
||||
// viewport rect (reliable after remount) rather than reading clientHeight
|
||||
// at restore time, which can be 0 while the tree is still settling.
|
||||
const viewportHeight = virtualizer.scrollRect?.height ?? treeRef.current?.clientHeight ?? 0;
|
||||
if (virtualizer.getTotalSize() - viewportHeight < target) return; // not enough scrollable space yet
|
||||
virtualizer.scrollToOffset(target, { align: "start" });
|
||||
pendingScrollRestoreRef.current = null;
|
||||
}, [open, treeRows.length, virtualizer.getTotalSize(), virtualizer.scrollRect?.height, workspaceMemoryKey, virtualizer]);
|
||||
|
||||
const virtualTreeItems = virtualizer.getVirtualItems();
|
||||
const compactProbePaths = virtualTreeItems
|
||||
.map((item) => treeRows[item.index])
|
||||
.filter((row): row is TreeRow => Boolean(row?.entry.isDir && entriesByDir[row.path] === undefined))
|
||||
.filter((row): row is WorkspaceTreeRowData => Boolean(row?.entry.isDir && entriesByDir[row.path] === undefined))
|
||||
.map((row) => row.path);
|
||||
const compactProbeKey = compactProbePaths.join("\u0000");
|
||||
|
||||
@@ -1265,6 +1321,7 @@ export function WorkspacePanel({
|
||||
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
|
||||
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
|
||||
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
|
||||
treeOnRight: true,
|
||||
});
|
||||
liveResize.schedule(nextWidth);
|
||||
};
|
||||
@@ -1291,7 +1348,7 @@ export function WorkspacePanel({
|
||||
(event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowRight" ? 16 : -16));
|
||||
setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowLeft" ? 16 : -16));
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setSavedTreeWidth(WORKSPACE_TREE_MIN_WIDTH);
|
||||
@@ -1407,96 +1464,14 @@ export function WorkspacePanel({
|
||||
}
|
||||
};
|
||||
|
||||
const renderNormalRow = (row: TreeRow) => {
|
||||
const { path, depth, entry, isOpen, active, compactPaths = [path], displayName = entry.name } = row;
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
|
||||
data-workspace-path={path}
|
||||
draggable
|
||||
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
|
||||
onClick={() => {
|
||||
if (entry.isDir) {
|
||||
toggleDir(path, compactPaths);
|
||||
} else {
|
||||
if (selectedPath === path) {
|
||||
setSelectedFilePath(null);
|
||||
} else {
|
||||
selectFile(path);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
{depth > 0 && (
|
||||
<span className="workspace-tree__guides" aria-hidden="true">
|
||||
{Array.from({ length: depth }, (_, index) => (
|
||||
<span
|
||||
className="workspace-tree__guide"
|
||||
key={index}
|
||||
style={{ left: 14 + index * 14 }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{entry.isDir ? (
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className={`workspace-tree__chev ${isOpen ? "workspace-tree__chev--open" : ""}`}
|
||||
style={{
|
||||
transition: "transform 0.15s ease",
|
||||
transform: isOpen ? "rotate(90deg)" : "rotate(0deg)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="workspace-tree__chev" />
|
||||
)}
|
||||
{entry.isDir ? (
|
||||
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
|
||||
) : (
|
||||
<WorkspaceFileIcon fileName={entry.name} />
|
||||
)}
|
||||
<span className="workspace-tree__name">{displayName}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSearchRow = (row: TreeRow) => {
|
||||
const { path, entry, active } = row;
|
||||
const dir = parentPath(path);
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
className={`workspace-tree__row workspace-tree__row--search${active ? " workspace-tree__row--active" : ""}`}
|
||||
data-workspace-path={path}
|
||||
draggable
|
||||
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
|
||||
onClick={() => {
|
||||
if (entry.isDir) {
|
||||
toggleDir(path);
|
||||
} else {
|
||||
if (selectedPath === path) {
|
||||
setSelectedFilePath(null);
|
||||
} else {
|
||||
selectFile(path);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
|
||||
>
|
||||
{entry.isDir ? (
|
||||
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
|
||||
) : (
|
||||
<WorkspaceFileIcon fileName={entry.name} />
|
||||
)}
|
||||
<span className="workspace-tree__result">
|
||||
<span className="workspace-tree__result-name">{basename(path)}</span>
|
||||
{dir && <span className="workspace-tree__result-dir">{dir}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
const activateTreeRow = (row: WorkspaceTreeRowData) => {
|
||||
if (row.entry.isDir) {
|
||||
toggleDir(row.path, row.compactPaths ?? [row.path]);
|
||||
} else if (selectedPath === row.path) {
|
||||
setSelectedFilePath(null);
|
||||
} else {
|
||||
selectFile(row.path);
|
||||
}
|
||||
};
|
||||
|
||||
const isMarkdown = selectedPath?.toLowerCase().endsWith(".md") ?? false;
|
||||
@@ -1511,6 +1486,18 @@ export function WorkspacePanel({
|
||||
!preview.binary &&
|
||||
!isMarkdown,
|
||||
);
|
||||
// The preview body must keep its flex-column layout while a code file is
|
||||
// loading. Switching it to the padded block layout mid-load and back again
|
||||
// makes WebKit leave stale gaps between rows once the viewer mounts.
|
||||
const codePreviewLayoutActive = Boolean(
|
||||
selectedFilePath &&
|
||||
!changedMode &&
|
||||
!isMarkdown &&
|
||||
!previewErr &&
|
||||
!preview?.err &&
|
||||
!preview?.kind &&
|
||||
!preview?.binary,
|
||||
);
|
||||
const openCodeSearch = () => {
|
||||
if (!codePreviewActive || !selectedPath) return;
|
||||
setCodeSearchRequestPath(selectedPath);
|
||||
@@ -1556,7 +1543,8 @@ export function WorkspacePanel({
|
||||
<Tooltip label={selectedPath ?? undefined}>
|
||||
<span className="workspace-current-file__name">{previewTitle}</span>
|
||||
</Tooltip>
|
||||
{previewSubtitle && <span className="workspace-current-file__path">{previewSubtitle}</span>}
|
||||
<WorkspacePathBreadcrumbs crumbs={previewSubtitleCrumbs} />
|
||||
<WorkspacePathBreadcrumbs crumbs={previewCrumbs} />
|
||||
</div>
|
||||
<Tooltip label={t("workspace.recentFiles")}>
|
||||
<button
|
||||
@@ -1591,6 +1579,19 @@ export function WorkspacePanel({
|
||||
{maximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{previewVisible && !changedMode && (
|
||||
<Tooltip label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}>
|
||||
<button
|
||||
className={`workspace-iconbtn${actualTreeVisible ? " workspace-iconbtn--on" : ""}`}
|
||||
type="button"
|
||||
aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}
|
||||
aria-pressed={actualTreeVisible}
|
||||
onClick={toggleTreeRail}
|
||||
>
|
||||
<FolderTree size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedPath && (
|
||||
<Tooltip label={t("workspace.closePreview")}>
|
||||
<button className="workspace-iconbtn" onClick={closePreviewArea}>
|
||||
@@ -1672,7 +1673,7 @@ export function WorkspacePanel({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`workspace-preview__body${selectedPath && !changedMode && !isMarkdown && !previewErr && !preview?.err && !preview?.kind && !preview?.binary ? " workspace-preview__body--code" : ""}`}
|
||||
className={`workspace-preview__body${codePreviewLayoutActive ? " workspace-preview__body--code" : ""}`}
|
||||
ref={previewBodyRef}
|
||||
onContextMenu={openSelectionMenu}
|
||||
onMouseUp={showSelectionToolbar}
|
||||
@@ -2022,22 +2023,6 @@ export function WorkspacePanel({
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{showTreeRail && (
|
||||
<section className="workspace-tree-rail" aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}>
|
||||
<Tooltip label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")} side="right">
|
||||
<button
|
||||
className={`workspace-tree-reveal workspace-iconbtn${actualTreeVisible ? " workspace-iconbtn--on" : ""}`}
|
||||
type="button"
|
||||
aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}
|
||||
aria-pressed={actualTreeVisible}
|
||||
onClick={toggleTreeRail}
|
||||
>
|
||||
<FolderTree size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{actualTreeVisible && previewVisible && (
|
||||
<button
|
||||
className="workspace-tree-resizer"
|
||||
@@ -2141,8 +2126,8 @@ export function WorkspacePanel({
|
||||
<div
|
||||
className="workspace-tree"
|
||||
ref={treeRef}
|
||||
onContextMenu={openTreeBlankMenu}
|
||||
onScroll={onWorkspaceTreeScroll}
|
||||
onContextMenu={openTreeBlankMenu}
|
||||
style={{
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
@@ -2173,7 +2158,12 @@ export function WorkspacePanel({
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{item.isSearch ? renderSearchRow(item) : renderNormalRow(item)}
|
||||
<WorkspaceTreeRow
|
||||
row={item}
|
||||
onActivate={activateTreeRow}
|
||||
onDragStart={startTreeDrag}
|
||||
onContextMenu={openTreeMenu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { workspaceBasename as basename, workspaceParentPath as parentPath } from "../lib/workspacePanelFormat";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
export interface WorkspacePathBreadcrumb {
|
||||
label: string;
|
||||
full: string;
|
||||
}
|
||||
|
||||
export function buildWorkspacePathBreadcrumbs(cwd: string | undefined, path: string): WorkspacePathBreadcrumb[] {
|
||||
if (!path) return [];
|
||||
const root = basename(cwd ?? "");
|
||||
const dirParts = parentPath(path).split("/").filter(Boolean);
|
||||
const dirCrumbs: WorkspacePathBreadcrumb[] = [];
|
||||
let currentPath = "";
|
||||
for (const part of dirParts) {
|
||||
currentPath += `${currentPath ? "/" : ""}${part}`;
|
||||
dirCrumbs.push({ label: part, full: `${cwd ?? ""}/${currentPath}` });
|
||||
}
|
||||
const crumbs = [{ label: root, full: cwd ?? "" }];
|
||||
if (dirParts.length > 0 && dirParts[0] !== root) crumbs.push(...dirCrumbs);
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
export function WorkspacePathBreadcrumbs({ crumbs }: { crumbs: WorkspacePathBreadcrumb[] }) {
|
||||
if (crumbs.length === 0) return null;
|
||||
return (
|
||||
<span className="workspace-current-file__path">
|
||||
{crumbs.map((crumb, index) => (
|
||||
<span key={index} className="workspace-current-file__crumb">
|
||||
{index > 0 && <span className="workspace-current-file__crumb-sep" aria-hidden="true">›</span>}
|
||||
<Tooltip label={crumb.full}>
|
||||
<span>{crumb.label}</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { DragEvent, MouseEvent } from "react";
|
||||
import { ChevronRight, Folder } from "lucide-react";
|
||||
import type { DirEntry } from "../lib/types";
|
||||
import { workspaceBasename as basename, workspaceParentPath as parentPath } from "../lib/workspacePanelFormat";
|
||||
import { WorkspaceFileIcon } from "./WorkspaceFileIcon";
|
||||
|
||||
export interface WorkspaceTreeRowData {
|
||||
key: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
entry: DirEntry;
|
||||
active: boolean;
|
||||
isOpen?: boolean;
|
||||
isSearch?: boolean;
|
||||
compactPaths?: string[];
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export function WorkspaceTreeRow({
|
||||
row,
|
||||
onActivate,
|
||||
onDragStart,
|
||||
onContextMenu,
|
||||
}: {
|
||||
row: WorkspaceTreeRowData;
|
||||
onActivate: (row: WorkspaceTreeRowData) => void;
|
||||
onDragStart: (event: DragEvent<HTMLElement>, path: string, isDir: boolean) => void;
|
||||
onContextMenu: (event: MouseEvent<HTMLElement>, path: string, isDir: boolean) => void;
|
||||
}) {
|
||||
const { path, depth, entry, isOpen, active, displayName = entry.name } = row;
|
||||
if (row.isSearch) {
|
||||
const dir = parentPath(path);
|
||||
return (
|
||||
<button
|
||||
className={`workspace-tree__row workspace-tree__row--search${active ? " workspace-tree__row--active" : ""}`}
|
||||
data-workspace-path={path}
|
||||
draggable
|
||||
onDragStart={(event) => onDragStart(event, path, entry.isDir)}
|
||||
onClick={() => onActivate(row)}
|
||||
onContextMenu={(event) => onContextMenu(event, path, entry.isDir)}
|
||||
>
|
||||
{entry.isDir ? (
|
||||
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
|
||||
) : (
|
||||
<WorkspaceFileIcon fileName={entry.name} />
|
||||
)}
|
||||
<span className="workspace-tree__result">
|
||||
<span className="workspace-tree__result-name">{basename(path)}</span>
|
||||
{dir && <span className="workspace-tree__result-dir">{dir}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
|
||||
data-workspace-path={path}
|
||||
draggable
|
||||
onDragStart={(event) => onDragStart(event, path, entry.isDir)}
|
||||
onClick={() => onActivate(row)}
|
||||
onContextMenu={(event) => onContextMenu(event, path, entry.isDir)}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
{depth > 0 && (
|
||||
<span className="workspace-tree__guides" aria-hidden="true">
|
||||
{Array.from({ length: depth }, (_, index) => (
|
||||
<span
|
||||
className="workspace-tree__guide"
|
||||
key={index}
|
||||
style={{ left: 14 + index * 14 }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{entry.isDir ? (
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className={`workspace-tree__chev ${isOpen ? "workspace-tree__chev--open" : ""}`}
|
||||
style={{
|
||||
transition: "transform 0.15s ease",
|
||||
transform: isOpen ? "rotate(90deg)" : "rotate(0deg)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="workspace-tree__chev" />
|
||||
)}
|
||||
{entry.isDir ? (
|
||||
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
|
||||
) : (
|
||||
<WorkspaceFileIcon fileName={entry.name} />
|
||||
)}
|
||||
<span className="workspace-tree__name">{displayName}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -346,6 +346,28 @@ export default function LineNumberCode({
|
||||
directDomUpdates: true,
|
||||
});
|
||||
|
||||
// The virtualizer writes positioning straight onto DOM nodes (container
|
||||
// height, scroll offset, per-row transforms). When a large (virtual) file
|
||||
// stays mounted via SWR and is replaced by a small (non-virtual) file, React
|
||||
// reuses those nodes; clear the direct DOM writes so the non-virtual rows
|
||||
// lay out fresh instead of keeping stale offsets and gaps.
|
||||
useEffect(() => {
|
||||
if (isVirtual) return;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
scrollEl.scrollTop = 0;
|
||||
const wrap = scrollEl.querySelector<HTMLElement>(".code-lines-wrap");
|
||||
if (!wrap) return;
|
||||
wrap.style.removeProperty("height");
|
||||
wrap.querySelectorAll<HTMLElement>("[data-line-index]").forEach((row) => {
|
||||
row.style.removeProperty("transform");
|
||||
row.style.removeProperty("position");
|
||||
row.style.removeProperty("top");
|
||||
row.style.removeProperty("left");
|
||||
row.style.removeProperty("width");
|
||||
});
|
||||
}, [isVirtual]);
|
||||
|
||||
const scrollToLine = useCallback(
|
||||
(index: number) => {
|
||||
if (!scrollRef.current) return;
|
||||
@@ -407,6 +429,7 @@ export default function LineNumberCode({
|
||||
key={index}
|
||||
data-line-index={index}
|
||||
className={`code-line-row${isCurrent ? " code-line-row--current" : ""}${isDimmed ? " code-line-row--dim" : ""}`}
|
||||
style={{ transform: "none" }}
|
||||
>
|
||||
{showLineNumbers !== false && (
|
||||
<span
|
||||
@@ -615,6 +638,12 @@ export default function LineNumberCode({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
// directDomUpdates writes the transform straight onto the
|
||||
// DOM, but right after switching a non-virtual view to a
|
||||
// virtual one the virtualizer may emit before its element
|
||||
// cache is populated; rendering the offset here keeps the
|
||||
// rows positioned (no stack-up) until then.
|
||||
transform: `translate3d(0, ${row.start}px, 0)`,
|
||||
}}
|
||||
>
|
||||
{renderRow(row.index)}
|
||||
@@ -622,7 +651,7 @@ export default function LineNumberCode({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="code-lines-wrap">
|
||||
<div className="code-lines-wrap" style={{ height: undefined }}>
|
||||
{lines.map((_, index) => renderRow(index))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,6 +29,10 @@ export function useWorkspaceTreeScrollPersistence<T extends HTMLElement>({
|
||||
}, [memoryKey, open, scrollRef]);
|
||||
|
||||
return useCallback((event: UIEvent<T>) => {
|
||||
// Only user-initiated scrolling is persisted: tree rebuilds reset the
|
||||
// container to scrollTop 0 via script (isTrusted=false), and persisting
|
||||
// that 0 would clobber the saved offset so restoration jumps back to top.
|
||||
if (!event.nativeEvent.isTrusted) return;
|
||||
rememberWorkspaceTreeScroll(memoryKey, event.currentTarget.scrollTop);
|
||||
}, [memoryKey]);
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export function workspaceSplitTreeWidthFromPointer({
|
||||
railWidth = 0,
|
||||
treeMinWidth,
|
||||
previewMinWidth,
|
||||
treeOnRight = false,
|
||||
}: {
|
||||
clientX: number;
|
||||
panelLeft: number;
|
||||
@@ -119,7 +120,19 @@ export function workspaceSplitTreeWidthFromPointer({
|
||||
railWidth?: number;
|
||||
treeMinWidth: number;
|
||||
previewMinWidth: number;
|
||||
treeOnRight?: boolean;
|
||||
}): number {
|
||||
if (treeOnRight && typeof panelWidth === "number") {
|
||||
// Tree width is the distance from the pointer to the panel's right edge.
|
||||
// The rail still occupies its own column on the opposite side.
|
||||
return clampWorkspaceSplitTreeWidth({
|
||||
width: panelLeft + panelWidth - clientX,
|
||||
panelWidth,
|
||||
railWidth,
|
||||
treeMinWidth,
|
||||
previewMinWidth,
|
||||
});
|
||||
}
|
||||
return clampWorkspaceSplitTreeWidth({
|
||||
width: clientX - panelLeft - railWidth,
|
||||
panelWidth,
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface WorkspaceTreeMemorySnapshot {
|
||||
scrollTop: number;
|
||||
dockTreeWidth: number | null;
|
||||
dockPreviewWidth: number | null;
|
||||
recentPaths: string[];
|
||||
}
|
||||
|
||||
type PersistedWorkspaceState = Omit<WorkspaceTreeMemorySnapshot, "openDirs" | "visitId"> & {
|
||||
@@ -26,6 +27,7 @@ interface PersistedWorkspaceEnvelope {
|
||||
|
||||
const STORAGE_KEY = "reasonix.workspaceState.v2";
|
||||
const MAX_PERSISTED_PROJECTS = 50;
|
||||
const MAX_RECENT_PATHS = 10;
|
||||
const workspaceTreeMemory = new Map<string, WorkspaceTreeMemorySnapshot>();
|
||||
let storageHydrated = false;
|
||||
let activeWorkspaceTreeKey = "";
|
||||
@@ -42,6 +44,7 @@ function defaultSnapshot(visitId = 0): WorkspaceTreeMemorySnapshot {
|
||||
scrollTop: 0,
|
||||
dockTreeWidth: null,
|
||||
dockPreviewWidth: null,
|
||||
recentPaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +83,9 @@ function hydrateWorkspaceTreeMemory(): void {
|
||||
dockPreviewWidth: typeof state.dockPreviewWidth === "number" && Number.isFinite(state.dockPreviewWidth) && state.dockPreviewWidth > 0
|
||||
? state.dockPreviewWidth
|
||||
: null,
|
||||
recentPaths: Array.isArray(state.recentPaths)
|
||||
? state.recentPaths.filter((path): path is string => typeof path === "string").slice(0, MAX_RECENT_PATHS)
|
||||
: [],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -103,6 +109,7 @@ function persistWorkspaceTreeMemory(recentKey: string): void {
|
||||
scrollTop: snapshot.scrollTop,
|
||||
dockTreeWidth: snapshot.dockTreeWidth,
|
||||
dockPreviewWidth: snapshot.dockPreviewWidth,
|
||||
recentPaths: snapshot.recentPaths,
|
||||
updatedAt: key === recentKey ? now : 0,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -56,20 +56,25 @@ function clampStoredSidebarWidth(width: number): number {
|
||||
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(CREATION_SIDEBAR_MIN_WIDTH, Math.round(width)));
|
||||
}
|
||||
|
||||
export function clampRightDockPreviewWidth(width: number): number {
|
||||
return Math.min(RIGHT_DOCK_MAX_WIDTH, Math.max(RIGHT_DOCK_PREVIEW_MIN_WIDTH, Math.round(width)));
|
||||
export function clampRightDockPreviewWidth(width: number, maxWidth = RIGHT_DOCK_MAX_WIDTH): number {
|
||||
// Cap at maxWidth (which may be below the default maximum when the viewport
|
||||
// is narrow) while never dropping below the applicable minimum.
|
||||
return Math.min(Math.max(maxWidth, RIGHT_DOCK_PREVIEW_MIN_WIDTH), Math.max(RIGHT_DOCK_PREVIEW_MIN_WIDTH, Math.round(width)));
|
||||
}
|
||||
|
||||
export function clampRightDockTreeWidth(width: number): number {
|
||||
return Math.min(RIGHT_DOCK_TREE_MAX_WIDTH, Math.max(RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width)));
|
||||
export function clampRightDockTreeWidth(width: number, maxWidth = RIGHT_DOCK_TREE_MAX_WIDTH): number {
|
||||
return Math.min(Math.max(maxWidth, RIGHT_DOCK_TREE_MIN_WIDTH), Math.max(RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width)));
|
||||
}
|
||||
|
||||
export function clampCreationRightDockTreeWidth(width: number): number {
|
||||
return Math.min(RIGHT_DOCK_TREE_MAX_WIDTH, Math.max(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width)));
|
||||
export function clampCreationRightDockTreeWidth(width: number, maxWidth = RIGHT_DOCK_TREE_MAX_WIDTH): number {
|
||||
return Math.min(Math.max(maxWidth, CREATION_RIGHT_DOCK_TREE_MIN_WIDTH), Math.max(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width)));
|
||||
}
|
||||
|
||||
function clampStoredRightDockTreeWidth(width: number): number {
|
||||
return Math.min(RIGHT_DOCK_TREE_MAX_WIDTH, Math.max(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width)));
|
||||
// Stored widths are validated again against the live viewport at load time
|
||||
// (resolveWorkspacePanelWidth clamps to the chat pane's 400px floor), so
|
||||
// persistence only guards the sane lower bound and integer form.
|
||||
return Math.max(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width));
|
||||
}
|
||||
|
||||
export function defaultSidebarWidth(): number {
|
||||
@@ -202,21 +207,31 @@ export function clampTerminalHeight(height: number, viewportHeight: number): num
|
||||
return Math.min(max, Math.max(TERMINAL_MIN_HEIGHT, Math.round(height)));
|
||||
}
|
||||
|
||||
function loadWorkspacePanelOpen(): boolean {
|
||||
function workspacePanelOpenStorageKey(workspaceRoot: string): string {
|
||||
return workspaceRoot ? `${WORKSPACE_PANEL_OPEN_KEY}.${workspaceRoot}` : WORKSPACE_PANEL_OPEN_KEY;
|
||||
}
|
||||
|
||||
export function loadWorkspacePanelOpen(workspaceRoot: string): boolean {
|
||||
if (typeof window === "undefined") return WORKSPACE_PANEL_DEFAULT_OPEN;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(WORKSPACE_PANEL_OPEN_KEY);
|
||||
if (raw === null) return WORKSPACE_PANEL_DEFAULT_OPEN;
|
||||
return raw !== "0";
|
||||
const raw = window.localStorage.getItem(workspacePanelOpenStorageKey(workspaceRoot));
|
||||
if (raw !== null) return raw !== "0";
|
||||
// Migration: the legacy single global key predates per-project keys.
|
||||
// When a project has no stored preference yet, seed it from the old
|
||||
// global value so an upgrade does not flip a user's existing choice
|
||||
// (e.g. they had the dock closed; first open of any project stays closed).
|
||||
const legacyRaw = window.localStorage.getItem(WORKSPACE_PANEL_OPEN_KEY);
|
||||
if (legacyRaw !== null) return legacyRaw !== "0";
|
||||
return WORKSPACE_PANEL_DEFAULT_OPEN;
|
||||
} catch {
|
||||
return WORKSPACE_PANEL_DEFAULT_OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveWorkspacePanelOpen(open: boolean): void {
|
||||
export function saveWorkspacePanelOpen(open: boolean, workspaceRoot = ""): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(WORKSPACE_PANEL_OPEN_KEY, open ? "1" : "0");
|
||||
window.localStorage.setItem(workspacePanelOpenStorageKey(workspaceRoot), open ? "1" : "0");
|
||||
} catch {
|
||||
/* ignore storage failures */
|
||||
}
|
||||
@@ -250,7 +265,7 @@ export const useLayoutStore = create<LayoutState>((set) => ({
|
||||
sidebarWidth: loadSidebarWidth(),
|
||||
rightDockTreeWidth: loadRightDockTreeWidth(),
|
||||
rightDockPreviewWidth: loadRightDockPreviewWidth(),
|
||||
workspacePanelOpen: loadWorkspacePanelOpen(),
|
||||
workspacePanelOpen: loadWorkspacePanelOpen(""),
|
||||
workspacePanelMaximized: false,
|
||||
workspacePreviewActive: false,
|
||||
rightDockMode: "context",
|
||||
|
||||
+696
-242
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user