Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 11 additions & 8 deletions packages/app/src/components/dialog-select-directory-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import {
absoluteTreePath,
activeTreeNavigation,
advanceTreePreload,
nextSuggestionIndex,
nextTreeScrollTop,
Expand All @@ -21,6 +20,7 @@ import {
cleanPickerInput,
createPriorityTaskQueue,
createDirectorySearch,
createTreeNavigationLifecycle,
currentPickerSuggestions,
displayPickerPath,
pickerParent,
Expand Down Expand Up @@ -62,7 +62,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
let tree: FileTree | undefined
let container: HTMLDivElement | undefined
let pathArea: HTMLDivElement | undefined
let navigation = 0
const navigation = createTreeNavigationLifecycle()

const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
Expand Down Expand Up @@ -114,15 +114,15 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const request =
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
if (!navigation.active(generation)) return Promise.resolve(undefined)
return sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
.catch(() => undefined)
})
listings.set(key, request)
const nodes = await request
if (!activeTreeNavigation(generation, navigation)) return false
if (!navigation.active(generation)) return false
if (!nodes) {
listings.delete(key)
if (!key) setError(true)
Expand All @@ -138,7 +138,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
async function navigate(path: string) {
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
if (!value) return
const token = ++navigation
const token = navigation.begin()
setLoading(true)
setRootValid(false)
setSelected("")
Expand All @@ -150,7 +150,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
advanced.clear()
tree?.resetPaths([])
const valid = await load("", token)
if (!activeTreeNavigation(token, navigation)) return
if (!navigation.active(token)) return
setRootValid(valid)
setLoading(false)
}
Expand Down Expand Up @@ -246,7 +246,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
}
`,
onExpansionChange(change) {
if (change.expanded) void load(change.path, navigation)
if (change.expanded) void load(change.path, navigation.current())
},
onSelectionChange(paths) {
const path = paths.at(-1)
Expand All @@ -264,7 +264,10 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
void navigate(path)
})

onCleanup(() => tree?.cleanUp())
onCleanup(() => {
navigation.dispose()
tree?.cleanUp()
})

return (
<Dialog size="large" class="directory-picker-v2">
Expand Down
16 changes: 16 additions & 0 deletions packages/app/src/components/dialog-select-server-domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type ServerFormCallbacks = {
onFormComplete?: () => void
onFormInvalidated?: () => void
}

export function applyServerFormEvent(
event: "complete" | "invalidated",
reset: () => void,
options: ServerFormCallbacks = {},
action: () => void = () => {},
) {
reset()
action()
if (event === "complete") options.onFormComplete?.()
if (event === "invalidated") options.onFormInvalidated?.()
}
34 changes: 34 additions & 0 deletions packages/app/src/components/dialog-select-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test"
import { applyServerFormEvent } from "./dialog-select-server-domain"

describe("applyServerFormEvent", () => {
test("resets and reports successful completion", () => {
const calls: string[] = []

applyServerFormEvent(
"complete",
() => calls.push("reset"),
{ onFormComplete: () => calls.push("complete") },
() => calls.push("mutation"),
)

expect(calls).toEqual(["reset", "mutation", "complete"])
})

test("resets and reports external invalidation", () => {
const calls: string[] = []

applyServerFormEvent("invalidated", () => calls.push("reset"), {
onFormInvalidated: () => calls.push("invalidated"),
})

expect(calls).toEqual(["reset", "invalidated"])
})

test("keeps legacy reset behavior without callbacks", () => {
let resets = 0
applyServerFormEvent("complete", () => resets++)
applyServerFormEvent("invalidated", () => resets++)
expect(resets).toBe(2)
})
})
48 changes: 29 additions & 19 deletions packages/app/src/components/dialog-select-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@ import { normalizeServerUrl, ServerConnection, useServer } from "@/context/serve
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { applyServerFormEvent, type ServerFormCallbacks } from "./dialog-select-server-domain"

const DEFAULT_USERNAME = "opencode"

type ServerManagementControllerOptions = ServerFormCallbacks & {
onSelect?: () => void
navigateOnAdd?: boolean
}

interface ServerFormProps {
value: string
name: string
Expand Down Expand Up @@ -189,7 +195,7 @@ export function DialogSelectServer() {
)
}

export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
export function useServerManagementController(options: ServerManagementControllerOptions = {}) {
const navigate = useNavigate()
const server = useServer()
const tabs = useTabs()
Expand Down Expand Up @@ -247,7 +253,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}

Expand All @@ -264,36 +270,40 @@ export function useServerManagementController(options: { onSelect?: () => void;
return
}

resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
applyServerFormEvent("complete", resetAdd, options, () => {
server.add(conn)
options.onSelect?.()
})
return
}
resetAdd()
await select(conn, true)
options.onFormComplete?.()
},
}))

const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const original = input.original
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}

const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
const existingName = original.displayName
if (
normalized === input.original.http.url &&
normalized === original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
username === original.http.username &&
password === original.http.password
) {
resetEdit()
applyServerFormEvent("complete", resetEdit, options)
return
}

Expand All @@ -307,13 +317,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}

resetEdit()
applyServerFormEvent("complete", resetEdit, options, () => {
if (normalized === original.http.url) {
server.add(conn)
return
}
replaceServer(original, conn)
})
},
}))

Expand Down Expand Up @@ -506,7 +516,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
createEffect(() => {
if (!store.editServer.id) return
if (editing()) return
resetEdit()
applyServerFormEvent("invalidated", resetEdit, options)
})

async function handleRemove(key: ServerConnection.Key) {
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/components/directory-picker-domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
treePathWithin,
currentPickerSuggestions,
createDirectorySearch,
createTreeNavigationLifecycle,
createPriorityTaskQueue,
displayPickerPath,
pickerParent,
Expand Down Expand Up @@ -83,6 +84,15 @@ test("accepts mutations only from the active navigation", () => {
expect(activeTreeNavigation(2, 3)).toBeFalse()
})

test("invalidates directory navigation when its lifecycle is disposed", () => {
const navigation = createTreeNavigationLifecycle()
const initial = navigation.begin()

expect(navigation.active(initial)).toBeTrue()
navigation.dispose()
expect(navigation.active(initial)).toBeFalse()
})

test("preserves POSIX case while matching Windows drives case-insensitively", () => {
expect(treePathWithin("/repo", "/Repo")).toBeFalse()
expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue()
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/components/directory-picker-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,27 @@ export function activeTreeNavigation(request: number, current: number) {
return request === current
}

export function createTreeNavigationLifecycle() {
let generation = 0
let disposed = false

return {
begin() {
return ++generation
},
current() {
return generation
},
active(request: number) {
return !disposed && activeTreeNavigation(request, generation)
},
dispose() {
disposed = true
generation++
},
}
}

export function createPriorityTaskQueue<T>(concurrency: number) {
type Job = {
key: string
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/components/file-tree-v2-domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Filter } from "./file-tree"

export function effectiveFileTreeOpen(input: {
path: string
expanded: boolean
collapsed?: boolean
filter?: Pick<Filter, "dirs">
}) {
if (input.collapsed) return false
return input.expanded || input.filter?.dirs.has(input.path) === true
}
24 changes: 24 additions & 0 deletions packages/app/src/components/file-tree-v2.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { effectiveFileTreeOpen } from "./file-tree-v2-domain"

describe("effectiveFileTreeOpen", () => {
test("opens filtered ancestors without changing manual expansion", () => {
const filter = { dirs: new Set(["src", "src/components"]) }
const expanded = false

expect(effectiveFileTreeOpen({ path: "src", expanded, filter })).toBe(true)
expect(effectiveFileTreeOpen({ path: "src/components", expanded, filter })).toBe(true)
expect(expanded).toBe(false)
})

test("preserves an explicit collapse while filtering", () => {
const filter = { dirs: new Set(["src"]) }

expect(effectiveFileTreeOpen({ path: "src", expanded: false, filter, collapsed: true })).toBe(false)
})

test("preserves manual expansion outside a filter", () => {
expect(effectiveFileTreeOpen({ path: "src", expanded: true })).toBe(true)
expect(effectiveFileTreeOpen({ path: "src", expanded: false })).toBe(false)
})
})
Loading
Loading