Compare commits

...

1 Commits

Author SHA1 Message Date
Brendan Allan a6a0d77acb refactor(app): extract session side panel controller 2026-07-28 15:02:37 +08:00
3 changed files with 321 additions and 109 deletions
@@ -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 })
})
@@ -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<string | undefined>
allTabs: Accessor<string[]>
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<boolean>
canReview: Accessor<boolean>
fileBrowserEnabled: Accessor<boolean>
reviewPanelOpened: Accessor<boolean>
openReviewPanel: () => void
treeMode: Accessor<TreeMode>
setTreeMode: (mode: TreeMode) => void
fileReady: Accessor<boolean>
sessionKey: Accessor<string>
selectedLines: (path: string) => unknown
persistHandoff: (key: string, files: Record<string, SelectedLineRange | null>) => 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<Record<string, SelectedLineRange | null>>((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<typeof createSessionSidePanelController>
@@ -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<Record<string, SelectedLineRange | null>>((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 (
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
<aside
@@ -451,9 +382,7 @@ export function SessionSidePanel(props: {
iconSize="large"
class="!rounded-md"
onClick={() => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
void controller.dialog.openFile()
}}
aria-label={language.t("command.file.open")}
/>