mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 05:36:13 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a003687a |
@@ -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")}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user