Compare commits

..

1 Commits

Author SHA1 Message Date
Brendan Allan 3989bedd21 refactor(app): extract session side panel controller 2026-07-29 14:25:20 +08:00
4 changed files with 321 additions and 199 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")}
/>
-90
View File
@@ -1,90 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const describeUnix = process.platform === "win32" ? describe.skip : describe
const children: Bun.Subprocess[] = []
afterEach(() => children.splice(0).forEach((child) => child.kill()))
describeUnix("package-manager updater executable lifetime", () => {
test(
"replacing a globally installed native package orphans its running executable",
async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-updater-orphan-"))
const prefix = path.join(root, "prefix")
const env = {
...process.env,
BUN_INSTALL_GLOBAL_DIR: path.join(prefix, "install", "global"),
BUN_INSTALL_BIN: path.join(prefix, "bin"),
}
try {
const archives = await Promise.all(["1.0.0", "1.0.1"].map((version) => fixture(root, version)))
await run(["bun", "install", "--global", archives[0]], undefined, env)
const executable = path.join(prefix, "bin", "opencode-updater-orphan-fixture")
const child = Bun.spawn([executable], { stdout: "ignore", stderr: "ignore" })
children.push(child)
await Bun.sleep(100)
const before = await mappedExecutable(child.pid)
await run(["bun", "remove", "--global", "opencode-updater-orphan-fixture"], undefined, env)
await run(["bun", "install", "--global", archives[1]], undefined, env)
const after = await mappedExecutable(child.pid)
console.log(JSON.stringify({ pid: child.pid, before, after }))
expect(before).toContain("/node_modules/opencode-updater-orphan-fixture/bin/opencode-updater-orphan-fixture")
expect(after).toBe(`${before} (deleted)`)
expect(child.exitCode).toBeNull()
} finally {
await fs.rm(root, { recursive: true, force: true })
}
},
30_000,
)
})
async function fixture(root: string, version: string) {
const directory = path.join(root, version)
const source = path.join(directory, "fixture.c")
const executable = path.join(directory, "bin", "opencode-updater-orphan-fixture")
await fs.mkdir(path.dirname(executable), { recursive: true })
await fs.writeFile(
path.join(directory, "package.json"),
JSON.stringify({
name: "opencode-updater-orphan-fixture",
version,
bin: { "opencode-updater-orphan-fixture": "bin/opencode-updater-orphan-fixture" },
}),
)
await fs.writeFile(
source,
"#include <unistd.h>\nint main(void) { for (;;) { access(\".\", F_OK); usleep(10000); } }\n",
)
await run(["cc", source, "-o", executable])
await fs.chmod(executable, 0o755)
await run(["bun", "pm", "pack", "--destination", root], directory)
return path.join(root, `opencode-updater-orphan-fixture-${version}.tgz`)
}
async function mappedExecutable(pid: number) {
if (process.platform === "linux") return fs.readlink(`/proc/${pid}/exe`)
const result = await run(["lsof", "-a", "-p", String(pid), "-d", "txt", "-Fn"])
return result
.split("\n")
.find((line) => line.startsWith("n"))
?.slice(1)
}
async function run(command: string[], cwd?: string, env?: Record<string, string | undefined>) {
const result = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" })
const [code, stdout, stderr] = await Promise.all([
result.exited,
new Response(result.stdout).text(),
new Response(result.stderr).text(),
])
if (code === 0) return stdout.trim()
throw new Error(`${command.join(" ")} failed (${code}): ${stderr.trim()}`)
}