diff --git a/packages/app/src/pages/session/session-side-panel-controller.test.tsx b/packages/app/src/pages/session/session-side-panel-controller.test.tsx new file mode 100644 index 0000000000..c6bd20c391 --- /dev/null +++ b/packages/app/src/pages/session/session-side-panel-controller.test.tsx @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createStore } from "solid-js/store" +import { SESSION_OPEN_FILE_TAB } from "./helpers" +import { createSessionSidePanelController, sessionSidePanelHandoffFiles } from "./session-side-panel-controller" + +function createController(options?: { active?: string; all?: string[]; mode?: "changes" | "all" }) { + const calls: string[] = [] + const [state, setState] = createStore({ + active: options?.active, + all: options?.all ?? ["file://src/a.ts"], + preview: undefined as string | undefined, + mode: options?.mode ?? ("changes" as "changes" | "all"), + }) + return createRoot((dispose) => ({ + dispose, + calls, + state, + controller: createSessionSidePanelController({ + currentTab: () => state.active, + allTabs: () => state.all, + openTab: (tab) => calls.push(`open:${tab}`), + preview: (tab) => calls.push(`preview:${tab}`), + setActive: (tab) => calls.push(`active:${tab}`), + normalizeFileTab: (tab) => `file://${tab.slice("file://".length).toLowerCase()}`, + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + loadFile: (path) => calls.push(`load:${path}`), + reviewEnabled: () => true, + canReview: () => true, + fileBrowserEnabled: () => true, + reviewPanelOpened: () => false, + openReviewPanel: () => calls.push("panel"), + treeMode: () => state.mode, + setTreeMode: (mode) => setState("mode", mode), + fileReady: () => false, + sessionKey: () => "session", + selectedLines: () => null, + persistHandoff: () => undefined, + showDialog: () => undefined, + }), + })) +} + +describe("session side panel controller", () => { + test("normalizes and centralizes file tab selection mutations", async () => { + const owned = createController() + + owned.controller.tabs.activate("file://SRC/A.ts") + expect(owned.calls).toEqual(["load:src/a.ts", "panel", "active:file://src/a.ts"]) + + owned.calls.length = 0 + owned.controller.tabs.preview("file://SRC/B.ts") + expect(owned.calls).toEqual(["preview:file://src/b.ts", "load:src/b.ts", "panel"]) + await Promise.resolve() + expect(owned.calls).toEqual(["preview:file://src/b.ts", "load:src/b.ts", "panel", "active:file://src/b.ts"]) + + owned.calls.length = 0 + owned.controller.tabs.open("file://SRC/C.ts") + expect(owned.calls).toEqual(["open:file://src/c.ts", "load:src/c.ts", "panel", "active:file://src/c.ts"]) + owned.dispose() + }) + + test("derives browser selection and controls the tree mode", () => { + const owned = createController({ active: "file://src/a.ts", all: ["file://src/a.ts"] }) + + expect(owned.controller.browser.tab()).toBe("file://src/a.ts") + expect(owned.controller.browser.mounted()).toBe(true) + expect(owned.controller.browser.visible()).toBe(true) + + owned.controller.tree.setMode("invalid") + expect(owned.state.mode).toBe("changes") + owned.controller.tree.showAll() + expect(owned.state.mode).toBe("all") + owned.controller.tree.showAll() + expect(owned.state.mode).toBe("all") + + owned.calls.length = 0 + owned.controller.browser.open() + expect(owned.calls[0]).toBe(`preview:${SESSION_OPEN_FILE_TAB}`) + owned.dispose() + }) + + test("opens the file dialog with the tree handoff callback", async () => { + let render: (() => unknown) | undefined + let dialogProps: { mode?: "files"; onOpenFile?: (path: string) => void } | undefined + const owned = createController() + const controller = createSessionSidePanelController({ + currentTab: () => undefined, + allTabs: () => [], + openTab: () => undefined, + preview: () => undefined, + setActive: () => undefined, + normalizeFileTab: (tab) => tab, + pathFromTab: () => undefined, + loadFile: () => undefined, + reviewEnabled: () => true, + canReview: () => true, + fileBrowserEnabled: () => true, + reviewPanelOpened: () => true, + openReviewPanel: () => undefined, + treeMode: owned.controller.tree.mode, + setTreeMode: owned.controller.tree.setMode, + fileReady: () => false, + sessionKey: () => "session", + selectedLines: () => null, + persistHandoff: () => undefined, + showDialog: (value) => (render = value), + loadSelectFileDialog: async () => ({ + DialogSelectFile: (props) => { + dialogProps = props + return null + }, + }), + }) + + await controller.dialog.openFile() + render?.() + expect(dialogProps?.mode).toBe("files") + dialogProps?.onOpenFile?.("src/a.ts") + expect(owned.state.mode).toBe("all") + owned.dispose() + }) +}) + +test("projects only file tabs into handoff persistence", () => { + expect( + sessionSidePanelHandoffFiles( + ["review", "file://src/a.ts", "file://src/b.ts"], + (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + (path) => (path.endsWith("a.ts") ? { start: 2, end: 4 } : { startLine: 2, endLine: 4 }), + ), + ).toEqual({ "src/a.ts": { start: 2, end: 4 }, "src/b.ts": null }) +}) diff --git a/packages/app/src/pages/session/session-side-panel-controller.tsx b/packages/app/src/pages/session/session-side-panel-controller.tsx new file mode 100644 index 0000000000..3d13f33cd6 --- /dev/null +++ b/packages/app/src/pages/session/session-side-panel-controller.tsx @@ -0,0 +1,150 @@ +import { createComponent, createEffect, createMemo, type Accessor, type Component, type JSX } from "solid-js" +import type { SelectedLineRange } from "@/context/file" +import { SESSION_OPEN_FILE_TAB, createOpenSessionFileTab, createSessionTabs } from "@/pages/session/helpers" + +type TreeMode = "changes" | "all" + +type Input = { + currentTab: Accessor + allTabs: Accessor + openTab: (tab: string) => void + preview: (tab: string) => void + setActive: (tab: string) => void + normalizeFileTab: (tab: string) => string + pathFromTab: (tab: string) => string | undefined + loadFile: (path: string) => void + reviewEnabled: Accessor + canReview: Accessor + fileBrowserEnabled: Accessor + reviewPanelOpened: Accessor + openReviewPanel: () => void + treeMode: Accessor + setTreeMode: (mode: TreeMode) => void + fileReady: Accessor + sessionKey: Accessor + selectedLines: (path: string) => unknown + persistHandoff: (key: string, files: Record) => void + showDialog: (render: () => JSX.Element) => void + loadSelectFileDialog?: () => Promise<{ + DialogSelectFile: Component<{ mode?: "files"; onOpenFile?: (path: string) => void }> + }> +} + +export function createSessionSidePanelController(input: Input) { + const normalizeTab = (tab: string) => (tab.startsWith("file://") ? input.normalizeFileTab(tab) : tab) + const openReviewPanel = () => { + if (!input.reviewPanelOpened()) input.openReviewPanel() + } + const tabs = createSessionTabs({ + tabs: () => ({ active: input.currentTab, all: input.allTabs }), + pathFromTab: input.pathFromTab, + normalizeTab, + review: input.reviewEnabled, + hasReview: input.canReview, + fileBrowser: input.fileBrowserEnabled, + }) + const prepareTab = (tab: string) => { + const path = input.pathFromTab(tab) + if (path) input.loadFile(path) + openReviewPanel() + return tab + } + const open = createOpenSessionFileTab({ + normalizeTab, + openTab: input.openTab, + pathFromTab: input.pathFromTab, + loadFile: input.loadFile, + openReviewPanel, + setActive: input.setActive, + }) + const preview = (value: string) => { + const next = normalizeTab(value) + input.preview(next) + const selected = prepareTab(next) + queueMicrotask(() => input.setActive(selected)) + } + const activate = (value: string) => input.setActive(prepareTab(normalizeTab(value))) + const openFileBrowser = () => preview(SESSION_OPEN_FILE_TAB) + const browserTab = createMemo(() => { + if (!input.fileBrowserEnabled()) return undefined + const active = tabs.activeTab() + if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB + if (active && input.pathFromTab(active)) return active + return tabs.activeFileTab() + }) + // Keep the shell mounted while any file tab exists. Kobalte briefly selects + // Review while replacing a preview trigger, which must not reset sidebar scroll. + const fileBrowserMounted = createMemo( + () => + input.fileBrowserEnabled() && (tabs.openedTabs().length > 0 || tabs.openFileOpen() || browserTab() !== undefined), + ) + const fileBrowserVisible = createMemo(() => { + const active = tabs.activeTab() + return active !== "review" && active !== "context" && active !== "empty" + }) + const setTreeMode = (value: string) => { + if (value !== "changes" && value !== "all") return + input.setTreeMode(value) + } + const showAllFiles = () => { + if (input.treeMode() !== "changes") return + input.setTreeMode("all") + } + const openFileDialog = async () => { + const load = input.loadSelectFileDialog ?? (() => import("@/components/dialog-select-file")) + const { DialogSelectFile } = await load() + input.showDialog(() => createComponent(DialogSelectFile, { mode: "files", onOpenFile: showAllFiles })) + } + + createEffect(() => { + if (!input.fileReady()) return + input.persistHandoff( + input.sessionKey(), + sessionSidePanelHandoffFiles(input.allTabs(), input.pathFromTab, input.selectedLines), + ) + }) + + return { + tabs: { + ...tabs, + normalize: normalizeTab, + open, + preview, + activate, + }, + browser: { + tab: browserTab, + mounted: fileBrowserMounted, + visible: fileBrowserVisible, + open: openFileBrowser, + }, + tree: { + mode: input.treeMode, + setMode: setTreeMode, + showAll: showAllFiles, + }, + dialog: { + openFile: openFileDialog, + }, + } +} + +export function sessionSidePanelHandoffFiles( + tabs: readonly string[], + pathFromTab: (tab: string) => string | undefined, + selectedLines: (path: string) => unknown, +) { + return tabs.reduce>((files, tab) => { + const path = pathFromTab(tab) + if (!path) return files + const selected = selectedLines(path) + files[path] = isSelectedLineRange(selected) ? selected : null + return files + }, {}) +} + +function isSelectedLineRange(value: unknown): value is SelectedLineRange { + return !!value && typeof value === "object" && "start" in value && "end" in value +} + +export type SessionSidePanelController = ReturnType diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 22c52e73fe..cc74605cd3 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" +import { For, Match, Show, Switch, createMemo, onCleanup, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid" @@ -38,23 +38,17 @@ const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel" import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session" import { OpenInAppV2 } from "@/components/session/open-in-app-v2" import { useCommand } from "@/context/command" -import { useFile, type SelectedLineRange } from "@/context/file" +import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" -import { - SESSION_OPEN_FILE_TAB, - createOpenSessionFileTab, - createSessionTabs, - getTabReorderIndex, - shouldShowFileTree, - type Sizing, -} from "@/pages/session/helpers" +import { SESSION_OPEN_FILE_TAB, getTabReorderIndex, shouldShowFileTree, type Sizing } from "@/pages/session/helpers" import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +import { createSessionSidePanelController } from "@/pages/session/session-side-panel-controller" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff @@ -153,91 +147,49 @@ export function SessionSidePanel(props: { return file.tree.children("").length === 0 }) - const normalizeTab = (tab: string) => { - if (!tab.startsWith("file://")) return tab - return file.tab(tab) - } - - const openReviewPanel = () => { - if (!view().reviewPanel.opened()) view().reviewPanel.open() - } - - const openTab = createOpenSessionFileTab({ - normalizeTab, - openTab: tabs().open, + const controller = createSessionSidePanelController({ + currentTab: () => tabs().active(), + allTabs: () => tabs().all(), + openTab: (tab) => tabs().open(tab), + preview: (tab) => tabs().previewTab(tab), + setActive: (tab) => tabs().setActive(tab), + normalizeFileTab: file.tab, pathFromTab: file.pathFromTab, loadFile: file.load, - openReviewPanel, - setActive: tabs().setActive, + reviewEnabled: reviewTab, + canReview: props.canReview, + fileBrowserEnabled: () => !!props.fileBrowserState, + reviewPanelOpened: () => view().reviewPanel.opened(), + openReviewPanel: () => view().reviewPanel.open(), + treeMode: () => layout.fileTree.tab(), + setTreeMode: (mode) => layout.fileTree.setTab(mode), + fileReady: file.ready, + sessionKey, + selectedLines: file.selectedLines, + persistHandoff: (key, files) => setSessionHandoff(key, { files }), + showDialog: (render) => void dialog.show(render), }) - - const tabState = createSessionTabs({ - tabs, - pathFromTab: file.pathFromTab, - normalizeTab, - review: reviewTab, - hasReview: props.canReview, - fileBrowser: () => !!props.fileBrowserState, - }) - const contextOpen = tabState.contextOpen - const openFileOpen = tabState.openFileOpen - const panelTabs = tabState.panelTabs - const openedTabs = tabState.openedTabs - const activeTab = tabState.activeTab - const activeFileTab = tabState.activeFileTab - - const fileTreeTab = () => layout.fileTree.tab() - - const setFileTreeTabValue = (value: string) => { - if (value !== "changes" && value !== "all") return - layout.fileTree.setTab(value) - } - - const showAllFiles = () => { - if (fileTreeTab() !== "changes") return - layout.fileTree.setTab("all") - } + const contextOpen = controller.tabs.contextOpen + const panelTabs = controller.tabs.panelTabs + const openedTabs = controller.tabs.openedTabs + const activeTab = controller.tabs.activeTab + const activeFileTab = controller.tabs.activeFileTab + const openTab = controller.tabs.open + const previewTab = controller.tabs.preview + const activateTab = controller.tabs.activate + const browserTab = controller.browser.tab + const fileBrowserMounted = controller.browser.mounted + const fileBrowserVisible = controller.browser.visible + const fileTreeTab = controller.tree.mode + const setFileTreeTabValue = controller.tree.setMode let fileFilter: HTMLInputElement | undefined let tabList: HTMLDivElement | undefined const temporaryTab = tabs().preview - const previewTab = (value: string) => { - const next = normalizeTab(value) - tabs().previewTab(next) - const path = file.pathFromTab(next) - if (path) void file.load(path) - openReviewPanel() - queueMicrotask(() => tabs().setActive(next)) - } const openFileBrowser = () => { - previewTab(SESSION_OPEN_FILE_TAB) + controller.browser.open() queueMicrotask(() => fileFilter?.focus()) } - const activateTab = (value: string) => { - const next = normalizeTab(value) - const path = file.pathFromTab(next) - if (path) void file.load(path) - openReviewPanel() - tabs().setActive(next) - } - const browserTab = createMemo(() => { - if (!props.fileBrowserState) return undefined - const active = activeTab() - if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB - if (active && file.pathFromTab(active)) return active - return activeFileTab() - }) - // Keep the file-browser shell mounted while any file tab exists. Kobalte briefly - // selects Review while the tab For replaces a preview trigger, which would - // otherwise dispose the sidebar and reset scroll. - const fileBrowserMounted = createMemo(() => { - if (!props.fileBrowserState) return false - return openedTabs().length > 0 || openFileOpen() || !!browserTab() - }) - const fileBrowserVisible = createMemo(() => { - const active = activeTab() - return active !== "review" && active !== "context" && active !== "empty" - }) const openFileKeybind = createMemo(() => command.keybindParts("file.open")) const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) const [store, setStore] = createStore({ @@ -264,27 +216,6 @@ export function SessionSidePanel(props: { setStore("activeDraggable", undefined) } - createEffect(() => { - if (!file.ready()) return - - setSessionHandoff(sessionKey(), { - files: tabs() - .all() - .reduce>((acc, tab) => { - const path = file.pathFromTab(tab) - if (!path) return acc - - const selected = file.selectedLines(path) - acc[path] = - selected && typeof selected === "object" && "start" in selected && "end" in selected - ? (selected as SelectedLineRange) - : null - - return acc - }, {}), - }) - }) - return (