experiment: better web picker using @pierre/tree (#31208)

This commit is contained in:
Luke Parker 2026-06-16 17:43:23 +02:00 committed by GitHub
parent 25cb2be619
commit 88f5b9a90e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1297 additions and 211 deletions

View File

@ -35,6 +35,7 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3", "@solid-primitives/active-element": "2.1.3",
@ -922,6 +923,7 @@
"pacote@21.5.0": "patches/pacote@21.5.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
}, },
"overrides": { "overrides": {
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@ -2126,6 +2128,8 @@
"@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="],
"@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@ -4562,6 +4566,10 @@
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="],
"preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"pretty": ["pretty@2.0.0", "", { "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", "js-beautify": "^1.6.12" } }, "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w=="], "pretty": ["pretty@2.0.0", "", { "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", "js-beautify": "^1.6.12" } }, "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w=="],

View File

@ -150,6 +150,7 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch" "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch"
} }
} }

View File

@ -46,6 +46,7 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3", "@solid-primitives/active-element": "2.1.3",

View File

@ -0,0 +1,107 @@
.directory-picker-v2-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 12px;
padding: 2px 16px 0;
}
.directory-picker-v2-path {
position: relative;
z-index: 10;
display: flex;
gap: 8px;
}
.directory-picker-v2-actions {
display: flex;
flex-shrink: 0;
gap: 2px;
}
.directory-picker-v2-suggestions {
position: absolute;
z-index: 20;
top: 36px;
right: 0;
left: 0;
display: flex;
flex-direction: column;
padding: 4px;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
background: var(--v2-background-bg-layer-02);
box-shadow: var(--v2-elevation-overlay);
}
.directory-picker-v2-suggestions button {
overflow: hidden;
padding: 6px 8px;
border-radius: 4px;
color: var(--v2-text-text-muted);
font-size: 12px;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.directory-picker-v2-suggestions button:hover,
.directory-picker-v2-suggestions button[data-active] {
color: var(--v2-text-text-base);
background: var(--v2-overlay-simple-overlay-hover);
}
.directory-picker-v2-browser {
position: relative;
z-index: 0;
isolation: isolate;
min-height: 0;
flex: 1;
overflow: auto;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
background: transparent;
}
.directory-picker-v2-tree {
display: block;
width: 100%;
height: 100%;
--trees-bg-override: transparent;
--trees-fg-override: var(--v2-text-text-base);
--trees-fg-muted-override: var(--v2-text-text-muted);
--trees-bg-muted-override: transparent;
--trees-selected-bg-override: transparent;
--trees-selected-fg-override: var(--v2-text-text-base);
--trees-selected-focused-border-color-override: transparent;
--trees-focus-ring-color-override: transparent;
--trees-focus-ring-width-override: 0px;
--trees-focus-ring-offset-override: 0px;
--trees-border-color-override: var(--v2-border-border-base);
--trees-font-family-override: var(--font-family-sans);
--trees-font-size-override: 12px;
--trees-item-height: 24px;
--trees-border-radius-override: 4px;
}
.directory-picker-v2-state {
position: absolute;
z-index: 1;
inset: 0;
display: grid;
place-items: center;
color: var(--v2-text-text-muted);
font-size: 12px;
pointer-events: none;
}
.directory-picker-v2-selection {
overflow: hidden;
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -0,0 +1,340 @@
import "@pierre/trees/web-components"
import { FileTree } from "@pierre/trees"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import {
absoluteTreePath,
activeTreeNavigation,
advanceTreePreload,
nextSuggestionIndex,
nextTreeScrollTop,
pickerFileSearchQuery,
pickerAbsoluteInput,
pickerMode,
preloadTreeDirectories,
cleanPickerInput,
createDirectorySearch,
currentPickerSuggestions,
displayPickerPath,
pickerParent,
pickerRoot,
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
interface DialogSelectDirectoryV2Props {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
mode?: "directory" | "file"
start?: string
}
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const global = useGlobal()
const { sync, sdk } = global.createServerCtx(props.server)
const dialog = useDialog()
const language = useLanguage()
const policy = pickerMode(props.mode ?? "directory", props.start)
const action = {
file: language.t("dialog.directory.action.selectFile"),
directory: language.t("dialog.directory.action.selectFolder"),
}
const [root, setRoot] = createSignal("")
const [input, setInput] = createSignal("")
const [selected, setSelected] = createSignal("")
const [suggestionsOpen, setSuggestionsOpen] = createSignal(false)
const [activeSuggestion, setActiveSuggestion] = createSignal(-1)
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal(false)
const [rootValid, setRootValid] = createSignal(false)
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
const advanced = new Set<string>()
let tree: FileTree | undefined
let container: HTMLDivElement | undefined
let pathArea: HTMLDivElement | undefined
let navigation = 0
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
() => sdk.client.path.get().then((result) => result.data).catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() => props.start || sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const typed = cleanPickerInput(value).replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!typed || typed === current) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.client.find
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
.then((result) => result.data ?? [])
.catch(() => [])
const results = [
...directories,
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
]
return {
query: value,
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
}
})
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
async function load(path: string, generation: number, preload = true) {
const key = path.replace(/\/+$/, "")
setError(false)
const absolute = absoluteTreePath(root(), key)
const request =
listings.get(key) ??
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 (!nodes) {
listings.delete(key)
if (!key) setError(true)
return false
}
tree?.batch(
policy.entries(key, nodes).map((item) => ({ type: "add", path: item })),
)
if (preload && advanceTreePreload(advanced, key)) {
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
}
return true
}
async function navigate(path: string) {
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
if (!value) return
const token = ++navigation
setLoading(true)
setRootValid(false)
setSelected("")
setSuggestionsOpen(false)
setActiveSuggestion(-1)
setRoot(value)
setInput(displayPickerPath(value, value, home()))
listings.clear()
advanced.clear()
tree?.resetPaths([])
const valid = await load("", token)
if (!activeTreeNavigation(token, navigation)) return
setRootValid(valid)
setLoading(false)
}
function complete() {
const items = currentSuggestions()
const match = items[activeSuggestion()] ?? items[0]
if (!match) return
const value = displayPickerPath(match.absolute, input(), home())
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
if (match.type === "file") {
setSelected(
policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "",
)
setSuggestionsOpen(false)
setActiveSuggestion(-1)
}
}
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
if (suggestion.type === "directory") {
void navigate(suggestion.absolute)
return
}
setInput(displayPickerPath(suggestion.absolute, input(), home()))
setSelected(
policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "",
)
setSuggestionsOpen(false)
setActiveSuggestion(-1)
}
function moveSuggestion(delta: -1 | 1) {
setSuggestionsOpen(true)
setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length))
}
function activeSuggestionValue() {
const items = currentSuggestions()
return items[activeSuggestion()] ?? items[0]
}
const keyActions: Partial<Record<string, () => void>> = {
ArrowDown: () => moveSuggestion(1),
ArrowUp: () => moveSuggestion(-1),
Enter: () => {
const suggestion = activeSuggestionValue()
if (suggestion) chooseSuggestion(suggestion)
if (!suggestion) void navigate(input())
},
Tab: complete,
}
function handleInputKey(event: KeyboardEvent) {
const action = keyActions[event.key]
if (!action) return
if (event.key === "Tab" && event.shiftKey) return
event.preventDefault()
action()
}
function resolve() {
const path = policy.result(root(), selected(), rootValid())
if (!path) return
props.onSelect(props.multiple ? [path] : path)
dialog.close()
}
onMount(() => {
const closeSuggestions = (event: PointerEvent) => {
if (pathArea?.contains(event.target as Node)) return
setSuggestionsOpen(false)
setActiveSuggestion(-1)
}
document.addEventListener("pointerdown", closeSuggestions)
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
tree = new FileTree({
paths: [],
flattenEmptyDirectories: false,
initialExpansion: "closed",
stickyFolders: true,
unsafeCSS: `
button[data-type="item"] {
background: transparent !important;
box-shadow: none !important;
}
button[data-type="item"]:hover {
background: var(--v2-overlay-simple-overlay-hover) !important;
}
button[data-type="item"]:focus-visible {
outline: none !important;
box-shadow: none !important;
}
[data-file-tree-virtualized-scroll] {
overscroll-behavior: contain;
scrollbar-width: thin;
}
`,
onExpansionChange(change) {
if (change.expanded) void load(change.path, navigation)
},
onSelectionChange(paths) {
const path = paths.at(-1)
setSelected(path ? policy.selection(root(), path) ?? "" : "")
},
})
if (!container) return
tree.render({ containerWrapper: container })
tree.getFileTreeContainer()?.classList.add("directory-picker-v2-tree")
})
createEffect(() => {
const path = start()
if (!path || root()) return
void navigate(path)
})
onCleanup(() => tree?.cleanUp())
return (
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
<div class="directory-picker-v2-body">
<div class="directory-picker-v2-path" ref={pathArea}>
<TextInputV2
value={input()}
autofocus
autocomplete="off"
spellcheck={false}
class="!w-full"
onInput={(event) => {
setInput(cleanPickerInput(event.currentTarget.value))
setSelected("")
setSuggestionsOpen(true)
setActiveSuggestion(-1)
}}
role="combobox"
aria-autocomplete="list"
aria-expanded={suggestionsOpen()}
aria-controls="directory-picker-v2-suggestions"
aria-activedescendant={activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined}
onKeyDown={handleInputKey}
/>
<div class="directory-picker-v2-actions">
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(home())}>~</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>
{language.t("dialog.directory.root")}
</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>
{language.t("dialog.directory.parent")}
</ButtonV2>
</div>
<Show when={suggestionsOpen() && currentSuggestions().length > 0}>
<div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions">
<For each={currentSuggestions()}>
{(suggestion, index) => (
<button
id={`directory-picker-v2-suggestion-${index()}`}
role="option"
aria-selected={index() === activeSuggestion()}
data-active={index() === activeSuggestion() ? "" : undefined}
onPointerMove={() => setActiveSuggestion(index())}
onClick={() => chooseSuggestion(suggestion)}
>
{displayPickerPath(suggestion.absolute, input(), home())}
{suggestion.type === "directory" ? "/" : ""}
</button>
)}
</For>
</div>
</Show>
</div>
<div
class="directory-picker-v2-browser"
ref={container}
onWheel={(event) => {
const scroller = tree
?.getFileTreeContainer()
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
if (!scroller) return
const next = nextTreeScrollTop(scroller.scrollTop, event.deltaY, scroller.scrollHeight, scroller.clientHeight)
if (next === scroller.scrollTop) return
event.preventDefault()
scroller.scrollTop = next
scroller.dispatchEvent(new Event("scroll"))
}}
>
<Show when={loading()}><div class="directory-picker-v2-state">{language.t("common.loading")}</div></Show>
<Show when={!loading() && error()}>
<div class="directory-picker-v2-state">{language.t("dialog.directory.readError")}</div>
</Show>
</div>
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
</div>
<DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>{language.t("common.cancel")}</ButtonV2>
<ButtonV2 variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
{action[policy.action]}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}

View File

@ -4,12 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list" import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js" import { createMemo, createResource, createSignal } from "solid-js"
import { ServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
interface DialogSelectDirectoryProps { interface DialogSelectDirectoryProps {
title?: string title?: string
@ -24,89 +23,9 @@ type Row = {
group: "recent" | "folders" group: "recent" | "folders"
} }
function cleanInput(value: string) {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
function normalizePath(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
return v.replace(/\/+/g, "/")
}
function normalizeDriveRoot(input: string) {
const v = normalizePath(input)
if (/^[A-Za-z]:$/.test(v)) return v + "/"
return v
}
function trimTrailing(input: string) {
const v = normalizeDriveRoot(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
return v.replace(/\/+$/, "")
}
function joinPath(base: string | undefined, rel: string) {
const b = trimTrailing(base ?? "")
const r = trimTrailing(rel).replace(/^\/+/, "")
if (!b) return r
if (!r) return b
if (b.endsWith("/")) return b + r
return b + "/" + r
}
function rootOf(input: string) {
const v = normalizeDriveRoot(input)
if (v.startsWith("//")) return "//"
if (v.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
return ""
}
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function tildeOf(absolute: string, home: string) {
const full = trimTrailing(absolute)
if (!home) return ""
const hn = trimTrailing(home)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return ""
}
function displayPath(path: string, input: string, home: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full, home) || full
}
function toRow(absolute: string, home: string, group: Row["group"]): Row { function toRow(absolute: string, home: string, group: Row["group"]): Row {
const full = trimTrailing(absolute) const full = displayPickerPath(absolute, "", "")
const tilde = tildeOf(full, home) const tilde = displayPickerPath(full, "~", home)
const withSlash = (value: string) => { const withSlash = (value: string) => {
if (!value) return "" if (!value) return ""
if (value.endsWith("/")) return value if (value.endsWith("/")) return value
@ -128,120 +47,6 @@ function uniqueRows(rows: Row[]) {
}) })
} }
function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
const scoped = (value: string) => {
const base = args.start()
if (!base) return
const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" }
const h = args.home()
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
const root = rootOf(raw)
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
return { directory: trimTrailing(base), path: raw }
}
const dirs = async (dir: string) => {
const key = trimTrailing(dir)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((x) => x.data ?? [])
.catch(() => [])
.then((nodes) =>
nodes
.filter((n) => n.type === "directory")
.map((n) => ({
name: n.name,
absolute: trimTrailing(normalizeDriveRoot(n.absolute)),
})),
)
cache.set(key, request)
return request
}
const match = async (dir: string, query: string, limit: number) => {
const items = await dirs(dir)
if (!query) return items.slice(0, limit).map((x) => x.absolute)
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
}
return async (filter: string) => {
const token = ++current
const active = () => token === current
const value = cleanInput(filter)
const scopedInput = scoped(value)
if (!scopedInput) return [] as string[]
const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
const query = normalizeDriveRoot(scopedInput.path)
const find = () =>
args.sdk.client.find
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])
if (!isPath) {
const results = await find()
if (!active()) return []
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, segments.length - 1).filter((x) => x && x !== ".")
const tail = segments[segments.length - 1] ?? ""
const cap = 12
const branch = 4
let paths = [scopedInput.directory]
for (const part of head) {
if (!active()) return []
if (part === "..") {
paths = paths.map(parentOf)
continue
}
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
if (!active()) return []
paths = Array.from(new Set(next)).slice(0, cap)
if (paths.length === 0) return [] as string[]
}
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
if (!active()) return []
const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const expand = !raw.endsWith("/")
if (!expand || !tail) {
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
return items.slice(0, 50)
}
const needle = tail.toLowerCase()
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
const target = exact[0]
if (!target) return deduped.slice(0, 50)
const children = await match(target, "", 30)
if (!active()) return []
const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
}
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const global = useGlobal() const global = useGlobal()
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server) const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
@ -268,10 +73,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
) )
const directories = useDirectorySearch({ const directories = createDirectorySearch({
sdk, sdk,
home, home,
start, base: start,
}) })
const recentProjects = createMemo(() => { const recentProjects = createMemo(() => {
@ -336,7 +141,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open") group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
} }
ref={(r) => (list = r)} ref={(r) => (list = r)}
onFilter={(value) => setFilter(cleanInput(value))} onFilter={(value) => setFilter(cleanPickerInput(value))}
onKeyEvent={(e, item) => { onKeyEvent={(e, item) => {
if (e.key !== "Tab") return if (e.key !== "Tab") return
if (e.shiftKey) return if (e.shiftKey) return
@ -345,7 +150,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const value = displayPath(item.absolute, filter(), home()) const value = displayPickerPath(item.absolute, filter(), home())
list?.setFilter(value.endsWith("/") ? value : value + "/") list?.setFilter(value.endsWith("/") ? value : value + "/")
}} }}
onSelect={(path) => { onSelect={(path) => {
@ -354,7 +159,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}} }}
> >
{(item) => { {(item) => {
const path = displayPath(item.absolute, filter(), home()) const path = displayPickerPath(item.absolute, filter(), home())
if (path === "~") { if (path === "~") {
return ( return (
<div class="w-full flex items-center justify-between rounded-md"> <div class="w-full flex items-center justify-between rounded-md">

View File

@ -7,18 +7,25 @@ import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command" import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk" import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type EntryType = "command" | "file" | "session" type EntryType = "command" | "file" | "session"
type Entry = { type Entry = {
@ -264,6 +271,9 @@ function createSessionEntries(props: {
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const platform = usePlatform()
const server = useServer()
const settings = useSettings()
const layout = useLayout() const layout = useLayout()
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
@ -383,6 +393,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
state.cleanup?.() state.cleanup?.()
}) })
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) {
return (
<DialogSelectFileV2
server={server.current}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
open(result)
}}
/>
)
}
return ( return (
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition> <Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
<List <List

View File

@ -0,0 +1,191 @@
import { expect, test } from "bun:test"
import {
absoluteTreePath,
activeTreeNavigation,
advanceTreePreload,
nextSuggestionIndex,
nextTreeScrollTop,
pickerTreeEntries,
pickerSearchEntries,
pickerFileSearchQuery,
pickerMode,
preloadTreeDirectories,
selectedTreePath,
treeEntries,
treePathWithin,
currentPickerSuggestions,
createDirectorySearch,
displayPickerPath,
pickerParent,
pickerRoot,
pickerAbsoluteInput,
} from "./directory-picker-domain"
test("maps server directory entries into Pierre paths", () => {
expect(
treeEntries("src/", [
{ name: "components", type: "directory" },
{ name: "index.ts", type: "file" },
]),
).toEqual(["src/components/", "src/index.ts"])
})
test("maps Pierre paths back to the selected server root", () => {
expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components")
expect(absoluteTreePath("C:/", "")).toBe("C:/")
expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md")
expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md")
})
test("includes files only when the picker selects files", () => {
const nodes = [
{ name: "components", type: "directory" as const },
{ name: "index.ts", type: "file" as const },
]
expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"])
expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"])
})
test("includes files in file autocomplete while preserving directory navigation", () => {
const nodes = [
{ name: "src", absolute: "/repo/src", type: "directory" as const },
{ name: "README.md", absolute: "/repo/README.md", type: "file" as const },
]
expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]])
expect(pickerSearchEntries(nodes, "file")).toEqual(nodes)
})
test("centralizes file and directory selection policy", () => {
const file = pickerMode("file", "/repo")
expect(file.includeFiles).toBeTrue()
expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts")
expect(file.selection("/repo", "src/")).toBeUndefined()
expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts")
expect(file.selection("/tmp", "example.txt")).toBeUndefined()
expect(file.navigation("/repo/src")).toBe("/repo/src")
expect(file.navigation("/tmp")).toBeUndefined()
const directory = pickerMode("directory")
expect(directory.includeFiles).toBeFalse()
expect(directory.selection("/repo", "src/")).toBe("/repo/src")
expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos")
expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo")
expect(directory.navigation("/tmp")).toBe("/tmp")
expect(directory.result("/repo", "")).toBe("/repo")
expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke")
expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo")
expect(directory.result("/repo", "", false)).toBeUndefined()
})
test("accepts mutations only from the active navigation", () => {
expect(activeTreeNavigation(3, 3)).toBeTrue()
expect(activeTreeNavigation(2, 3)).toBeFalse()
})
test("preserves POSIX case while matching Windows drives case-insensitively", () => {
expect(treePathWithin("/repo", "/Repo")).toBeFalse()
expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue()
expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue()
expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe(
"src/file.ts",
)
expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse()
expect(treePathWithin("/", "/src")).toBeTrue()
expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts")
expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts")
})
test("displays paths using the selected server path format", () => {
expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe(
"C:\\Users\\luke\\repos",
)
expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe(
"C:\\Users\\luke\\repos",
)
expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos")
expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos")
})
test("treats the server share prefix as the UNC root", () => {
expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share")
expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share")
expect(pickerParent("//Server/Share")).toBe("//Server/Share")
expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share")
})
test("resolves relative input against the current picker root", () => {
expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src")
expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other")
expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config")
expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src")
})
test("exposes autocomplete results only for their source query", () => {
const result = { query: "/repo/src", items: ["/repo/src/index.ts"] }
expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items)
expect(currentPickerSuggestions(result, "/repo/test")).toEqual([])
})
test("scopes file autocomplete to the current browser root", () => {
expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in")
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
})
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const sdk = {
client: {
find: {
files: (input: { directory: string }) => {
directories.push(input.directory)
return Promise.resolve({ data: [] })
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
let base = "/repo"
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
await search("components")
base = "/repo/src"
await search("components")
expect(directories).toEqual(["/repo", "/repo/src"])
})
test("identifies the next directory level to preload", () => {
expect(
preloadTreeDirectories("src/", [
{ name: "components", type: "directory" },
{ name: "index.ts", type: "file" },
{ name: "utils", type: "directory" },
]),
).toEqual(["src/components/", "src/utils/"])
})
test("advances preloading once for every expanded directory", () => {
const advanced = new Set<string>()
expect(advanceTreePreload(advanced, "")).toBeTrue()
expect(advanceTreePreload(advanced, "")).toBeFalse()
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
})
test("clamps bridged tree wheel scrolling", () => {
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300)
})
test("wraps autocomplete keyboard navigation", () => {
expect(nextSuggestionIndex(-1, 1, 4)).toBe(0)
expect(nextSuggestionIndex(3, 1, 4)).toBe(0)
expect(nextSuggestionIndex(0, -1, 4)).toBe(3)
expect(nextSuggestionIndex(0, 1, 0)).toBe(-1)
})
test("returns absolute directories and relative files", () => {
expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src")
expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts")
expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts")
expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined()
})

View File

@ -0,0 +1,331 @@
export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
const prefix = parent.replace(/^\/+|\/+$/g, "")
return nodes.map((node) => {
const path = prefix ? `${prefix}/${node.name}` : node.name
return node.type === "directory" ? path + "/" : path
})
}
export function pickerTreeEntries(
parent: string,
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
mode: "directory" | "file",
) {
return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes)
}
export function pickerSearchEntries<T extends { type: "file" | "directory" }>(
nodes: readonly T[],
mode: "directory" | "file",
) {
return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes]
}
export function pickerMode(mode: "directory" | "file", base?: string) {
if (mode === "file") {
return {
includeFiles: true,
action: "file" as const,
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
return treeEntries(parent, nodes)
},
navigation(path: string) {
return treePathWithin(base, path) ? path : undefined
},
result(root: string, selected: string) {
return selected || undefined
},
selection(root: string, path: string) {
if (!treePathWithin(base, root)) return
return selectedTreePath(root, path, "file", base)
},
}
}
return {
includeFiles: false,
action: "directory" as const,
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
return treeEntries(
parent,
nodes.filter((node) => node.type === "directory"),
)
},
navigation(path: string) {
return path
},
result(root: string, selected: string, valid = true) {
if (!valid) return
return selected || (root ? nativePickerPath(root) : undefined)
},
selection(root: string, path: string) {
return selectedTreePath(root, path, "directory")
},
}
}
export function pickerFileSearchQuery(root: string, input: string, home: string) {
const value = input.replace(/\\/g, "/").replace(/^~(?=\/|$)/, home).replace(/\/+$/, "")
const base = root.replace(/\\/g, "/").replace(/\/+$/, "")
if (value === base) return ""
if (value.startsWith(base + "/")) return value.slice(base.length + 1)
return value
}
export function pickerAbsoluteInput(input: string, home: string, current: string) {
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
return canonicalPickerPath(absolute)
}
export function treePathWithin(base: string | undefined, path: string) {
return pickerRelativePath(base, path) !== undefined
}
export function canonicalPickerPath(path: string) {
const value = normalizePickerDrive(path)
const root = pickerRoot(value)
const parts = value.slice(root.length).split("/")
const resolved = parts.reduce<string[]>((output, part) => {
if (!part || part === ".") return output
if (part === "..") {
output.pop()
return output
}
output.push(part)
return output
}, [])
return joinPickerPath(root, resolved.join("/"))
}
export function pickerRelativePath(base: string | undefined, path: string) {
if (!base) return
const rootPath = canonicalPickerPath(base)
const targetPath = canonicalPickerPath(path)
const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//")
const root = insensitive ? rootPath.toLowerCase() : rootPath
const target = insensitive ? targetPath.toLowerCase() : targetPath
if (target === root) return ""
const prefix = root.endsWith("/") ? root : root + "/"
if (!target.startsWith(prefix)) return
return targetPath.slice(prefix.length)
}
export function currentPickerSuggestions<T>(
result: { query: string; items: readonly T[] } | undefined,
query: string,
) {
if (result?.query !== query) return []
return result.items
}
export function preloadTreeDirectories(
parent: string,
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
) {
return treeEntries(
parent,
nodes.filter((node) => node.type === "directory"),
)
}
export function advanceTreePreload(advanced: Set<string>, path: string) {
if (advanced.has(path)) return false
advanced.add(path)
return true
}
export function activeTreeNavigation(request: number, current: number) {
return request === current
}
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
}
export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) {
if (count === 0) return -1
return (current + delta + count) % count
}
export function absoluteTreePath(root: string, path: string) {
const base = trimPickerPath(root)
const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
if (!relative) return base || "/"
if (!base || base === "/") return "/" + relative
if (base.endsWith("/")) return base + relative
return `${base}/${relative}`
}
export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) {
const directory = path.endsWith("/")
if (mode === "file") {
if (directory) return
if (!base) return path
const absolute = absoluteTreePath(root, path)
return pickerRelativePath(base, absolute)
}
return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined
}
export function nativePickerPath(path: string) {
const value = trimPickerPath(path)
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
return value
}
import { getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort"
import { ServerSDK } from "@/context/server-sdk"
export function cleanPickerInput(value: string) {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
export function normalizePickerPath(input: string) {
const value = input.replaceAll("\\", "/")
if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/")
return value.replace(/\/+/g, "/")
}
export function normalizePickerDrive(input: string) {
const value = normalizePickerPath(input)
if (/^[A-Za-z]:$/.test(value)) return value + "/"
return value
}
export function trimPickerPath(input: string) {
const value = normalizePickerDrive(input)
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
return value.replace(/\/+$/, "")
}
export function joinPickerPath(base: string | undefined, relative: string) {
const root = trimPickerPath(base ?? "")
const path = trimPickerPath(relative).replace(/^\/+/, "")
if (!root) return path
if (!path) return root
if (root.endsWith("/")) return root + path
return root + "/" + path
}
export function pickerRoot(input: string) {
const value = normalizePickerDrive(input)
if (value.startsWith("//")) {
const [server, share] = value.slice(2).split("/")
if (server && share) return `//${server}/${share}`
return "//"
}
if (value.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3)
return ""
}
export function pickerParent(input: string) {
const value = trimPickerPath(input)
const root = pickerRoot(value)
if (value === root) return value
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
const index = value.lastIndexOf("/")
if (index < root.length) return root
if (index <= 0) return "/"
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
return value.slice(0, index)
}
function pickerTilde(absolute: string, home: string) {
const path = trimPickerPath(absolute)
if (!home) return ""
const root = trimPickerPath(home)
if (/^[A-Za-z]:\//.test(root)) return ""
if (path === root) return "~"
if (path.startsWith(root + "/")) return "~" + path.slice(root.length)
return ""
}
export function displayPickerPath(path: string, input: string, home: string) {
const value = trimPickerPath(path)
if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\")
return pickerTilde(value, home) || value
}
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
const scoped = (value: string) => {
const base = args.base()
if (!base) return
const raw = normalizePickerDrive(value)
if (!raw) return { directory: trimPickerPath(base), path: "" }
const home = args.home()
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
return { directory: trimPickerPath(base), path: raw }
}
const directories = async (directory: string) => {
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((result) => result.data ?? [])
.catch(() => [])
.then((nodes) =>
nodes
.filter((node) => node.type === "directory")
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
)
cache.set(key, request)
return request
}
const match = async (directory: string, query: string, limit: number) => {
const items = await directories(directory)
if (!query) return items.slice(0, limit).map((item) => item.absolute)
return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute)
}
return async (filter: string) => {
const token = ++current
const active = () => token === current
const value = cleanPickerInput(filter)
const input = scoped(value)
if (!input) return [] as string[]
const raw = normalizePickerDrive(value)
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
.then((result) => result.data ?? [])
.catch(() => [])
if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
const tail = segments.at(-1) ?? ""
let paths = [input.directory]
for (const part of head) {
if (!active()) return []
if (part === "..") {
paths = paths.map(pickerParent)
continue
}
paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12)
if (!active() || paths.length === 0) return []
}
const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat()))
if (!active()) return []
const base = raw.startsWith("~") ? trimPickerPath(input.directory) : ""
if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50)
const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase())
if (!target) return matches.slice(0, 50)
const children = await match(target, "", 30)
if (!active()) return []
return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50)
}
}

View File

@ -1,9 +1,15 @@
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { lazy } from "solid-js"
import { DialogSelectDirectory } from "./dialog-select-directory" import { DialogSelectDirectory } from "./dialog-select-directory"
import { directoryPickerKind } from "./directory-picker-policy" import { directoryPickerKind } from "./directory-picker-policy"
const DialogSelectDirectoryV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type DirectoryPickerInput = { type DirectoryPickerInput = {
server: ServerConnection.Any server: ServerConnection.Any
title?: string title?: string
@ -13,6 +19,7 @@ type DirectoryPickerInput = {
export function useDirectoryPicker() { export function useDirectoryPicker() {
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings()
const dialog = useDialog() const dialog = useDialog()
return (input: DirectoryPickerInput) => { return (input: DirectoryPickerInput) => {
@ -21,9 +28,18 @@ export function useDirectoryPicker() {
return return
} }
dialog.show( let selected = false
() => <DialogSelectDirectory {...input} />, const onSelect = (result: string | string[] | null) => {
() => input.onSelect(null), selected = result !== null
) input.onSelect(result)
}
const cancel = () => {
if (!selected) input.onSelect(null)
}
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
return
}
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
} }
} }

View File

@ -0,0 +1,23 @@
import { expect, test } from "bun:test"
import { FileTree, type FileTreeDirectoryHandle } from "@pierre/trees"
test("reports directory expansion changes", () => {
const changes: Array<{ path: string; expanded: boolean }> = []
const tree = new FileTree({
paths: ["src/"],
onExpansionChange: (change) => changes.push(change),
})
const src = tree.getItem("src/")
if (!src || !src.isDirectory()) throw new Error("Expected src to be a directory")
const directory = src as FileTreeDirectoryHandle
directory.expand()
directory.collapse()
expect(changes).toEqual([
{ path: "src/", expanded: true },
{ path: "src/", expanded: false },
])
tree.cleanUp()
})

View File

@ -1157,6 +1157,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
addPart, addPart,
readClipboardImage: platform.readClipboardImage, readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
}) })
const fileAttachmentInput = () => ( const fileAttachmentInput = () => (

View File

@ -33,6 +33,7 @@ type PromptAttachmentsInput = {
focusEditor: () => void focusEditor: () => void
addPart: (part: ContentPart) => boolean addPart: (part: ContentPart) => boolean
readClipboardImage?: () => Promise<File | null> readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
} }
export function createPromptAttachments(input: PromptAttachmentsInput) { export function createPromptAttachments(input: PromptAttachmentsInput) {
@ -63,6 +64,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
type: "image", type: "image",
id: uuid(), id: uuid(),
filename: file.name, filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
mime, mime,
dataUrl: url, dataUrl: url,
} }

View File

@ -75,6 +75,31 @@ describe("buildRequestParts", () => {
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"]) expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
}) })
test("preserves an external attachment source path for the model", () => {
const result = buildRequestParts({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
],
text: "inspect this",
messageID: "msg_external",
sessionID: "ses_external",
sessionDirectory: "C:\\Repos\\sst\\opencode",
})
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
})
test("deduplicates context files when prompt already includes same path", () => { test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]

View File

@ -188,7 +188,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
type: "file", type: "file",
mime: attachment.mime, mime: attachment.mime,
url: attachment.dataUrl, url: attachment.dataUrl,
filename: attachment.filename, filename: attachment.sourcePath ?? attachment.filename,
} satisfies PromptRequestPart } satisfies PromptRequestPart
}) })

View File

@ -55,6 +55,9 @@ type PlatformBase = {
onFile: (file: File) => Promise<unknown>, onFile: (file: File) => Promise<unknown>,
): Promise<void> ): Promise<void>
/** Resolve the native source path for a desktop File. */
getPathForFile?(file: File): string
/** Open a native save file picker dialog (desktop only) */ /** Open a native save file picker dialog (desktop only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null> saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>

View File

@ -33,6 +33,7 @@ export interface ImageAttachmentPart {
type: "image" type: "image"
id: string id: string
filename: string filename: string
sourcePath?: string
mime: string mime: string
dataUrl: string dataUrl: string
} }

View File

@ -281,6 +281,11 @@ export const dict = {
"dialog.fork.empty": "لا توجد رسائل للتفرع منها", "dialog.fork.empty": "لا توجد رسائل للتفرع منها",
"dialog.directory.search.placeholder": "البحث في المجلدات", "dialog.directory.search.placeholder": "البحث في المجلدات",
"dialog.directory.empty": "لم يتم العثور على مجلدات", "dialog.directory.empty": "لم يتم العثور على مجلدات",
"dialog.directory.action.selectFile": "اختيار ملف",
"dialog.directory.action.selectFolder": "اختيار مجلد",
"dialog.directory.root": "الجذر",
"dialog.directory.parent": "المجلد الأصل",
"dialog.directory.readError": "تعذرت قراءة هذا المجلد",
"dialog.server.title": "الخوادم", "dialog.server.title": "الخوادم",
"dialog.server.description": "تبديل خادم OpenCode الذي يتصل به هذا التطبيق.", "dialog.server.description": "تبديل خادم OpenCode الذي يتصل به هذا التطبيق.",
"dialog.server.search.placeholder": "البحث في الخوادم", "dialog.server.search.placeholder": "البحث في الخوادم",

View File

@ -281,6 +281,11 @@ export const dict = {
"dialog.fork.empty": "Nenhuma mensagem para bifurcar", "dialog.fork.empty": "Nenhuma mensagem para bifurcar",
"dialog.directory.search.placeholder": "Buscar pastas", "dialog.directory.search.placeholder": "Buscar pastas",
"dialog.directory.empty": "Nenhuma pasta encontrada", "dialog.directory.empty": "Nenhuma pasta encontrada",
"dialog.directory.action.selectFile": "Selecionar arquivo",
"dialog.directory.action.selectFolder": "Selecionar pasta",
"dialog.directory.root": "Raiz",
"dialog.directory.parent": "Pasta superior",
"dialog.directory.readError": "Não foi possível ler esta pasta",
"dialog.server.title": "Servidores", "dialog.server.title": "Servidores",
"dialog.server.description": "Trocar para qual servidor OpenCode este aplicativo se conecta.", "dialog.server.description": "Trocar para qual servidor OpenCode este aplicativo se conecta.",
"dialog.server.search.placeholder": "Buscar servidores", "dialog.server.search.placeholder": "Buscar servidores",

View File

@ -307,6 +307,11 @@ export const dict = {
"dialog.directory.search.placeholder": "Pretraži foldere", "dialog.directory.search.placeholder": "Pretraži foldere",
"dialog.directory.empty": "Nema pronađenih foldera", "dialog.directory.empty": "Nema pronađenih foldera",
"dialog.directory.action.selectFile": "Odaberi datoteku",
"dialog.directory.action.selectFolder": "Odaberi folder",
"dialog.directory.root": "Korijen",
"dialog.directory.parent": "Nadređeni folder",
"dialog.directory.readError": "Nije moguće pročitati ovaj folder",
"dialog.server.title": "Serveri", "dialog.server.title": "Serveri",
"dialog.server.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.", "dialog.server.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.",

View File

@ -305,6 +305,11 @@ export const dict = {
"dialog.directory.search.placeholder": "Søg mapper", "dialog.directory.search.placeholder": "Søg mapper",
"dialog.directory.empty": "Ingen mapper fundet", "dialog.directory.empty": "Ingen mapper fundet",
"dialog.directory.action.selectFile": "Vælg fil",
"dialog.directory.action.selectFolder": "Vælg mappe",
"dialog.directory.root": "Rod",
"dialog.directory.parent": "Overordnet",
"dialog.directory.readError": "Denne mappe kan ikke læses",
"dialog.server.title": "Servere", "dialog.server.title": "Servere",
"dialog.server.description": "Skift hvilken OpenCode-server denne app forbinder til.", "dialog.server.description": "Skift hvilken OpenCode-server denne app forbinder til.",

View File

@ -287,6 +287,11 @@ export const dict = {
"dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden", "dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden",
"dialog.directory.search.placeholder": "Ordner durchsuchen", "dialog.directory.search.placeholder": "Ordner durchsuchen",
"dialog.directory.empty": "Keine Ordner gefunden", "dialog.directory.empty": "Keine Ordner gefunden",
"dialog.directory.action.selectFile": "Datei auswählen",
"dialog.directory.action.selectFolder": "Ordner auswählen",
"dialog.directory.root": "Stammverzeichnis",
"dialog.directory.parent": "Übergeordnet",
"dialog.directory.readError": "Dieser Ordner kann nicht gelesen werden",
"dialog.server.title": "Server", "dialog.server.title": "Server",
"dialog.server.description": "Wechseln Sie den OpenCode-Server, mit dem sich diese App verbindet.", "dialog.server.description": "Wechseln Sie den OpenCode-Server, mit dem sich diese App verbindet.",
"dialog.server.search.placeholder": "Server durchsuchen", "dialog.server.search.placeholder": "Server durchsuchen",

View File

@ -313,6 +313,11 @@ export const dict = {
"dialog.directory.search.placeholder": "Search folders", "dialog.directory.search.placeholder": "Search folders",
"dialog.directory.empty": "No folders found", "dialog.directory.empty": "No folders found",
"dialog.directory.action.selectFile": "Select file",
"dialog.directory.action.selectFolder": "Select folder",
"dialog.directory.root": "Root",
"dialog.directory.parent": "Parent",
"dialog.directory.readError": "Unable to read this folder",
"app.server.unreachable": "Could not reach {{server}}", "app.server.unreachable": "Could not reach {{server}}",
"app.server.retrying": "Retrying automatically...", "app.server.retrying": "Retrying automatically...",

View File

@ -306,6 +306,11 @@ export const dict = {
"dialog.directory.search.placeholder": "Buscar carpetas", "dialog.directory.search.placeholder": "Buscar carpetas",
"dialog.directory.empty": "No se encontraron carpetas", "dialog.directory.empty": "No se encontraron carpetas",
"dialog.directory.action.selectFile": "Seleccionar archivo",
"dialog.directory.action.selectFolder": "Seleccionar carpeta",
"dialog.directory.root": "Raíz",
"dialog.directory.parent": "Superior",
"dialog.directory.readError": "No se puede leer esta carpeta",
"dialog.server.title": "Servidores", "dialog.server.title": "Servidores",
"dialog.server.description": "Cambiar a qué servidor de OpenCode se conecta esta app.", "dialog.server.description": "Cambiar a qué servidor de OpenCode se conecta esta app.",

View File

@ -282,6 +282,11 @@ export const dict = {
"dialog.fork.empty": "Aucun message à partir duquel bifurquer", "dialog.fork.empty": "Aucun message à partir duquel bifurquer",
"dialog.directory.search.placeholder": "Rechercher des dossiers", "dialog.directory.search.placeholder": "Rechercher des dossiers",
"dialog.directory.empty": "Aucun dossier trouvé", "dialog.directory.empty": "Aucun dossier trouvé",
"dialog.directory.action.selectFile": "Sélectionner le fichier",
"dialog.directory.action.selectFolder": "Sélectionner le dossier",
"dialog.directory.root": "Racine",
"dialog.directory.parent": "Parent",
"dialog.directory.readError": "Impossible de lire ce dossier",
"dialog.server.title": "Serveurs", "dialog.server.title": "Serveurs",
"dialog.server.description": "Changez le serveur OpenCode auquel cette application se connecte.", "dialog.server.description": "Changez le serveur OpenCode auquel cette application se connecte.",
"dialog.server.search.placeholder": "Rechercher des serveurs", "dialog.server.search.placeholder": "Rechercher des serveurs",

View File

@ -280,6 +280,11 @@ export const dict = {
"dialog.fork.empty": "フォーク元のメッセージがありません", "dialog.fork.empty": "フォーク元のメッセージがありません",
"dialog.directory.search.placeholder": "フォルダを検索", "dialog.directory.search.placeholder": "フォルダを検索",
"dialog.directory.empty": "フォルダが見つかりません", "dialog.directory.empty": "フォルダが見つかりません",
"dialog.directory.action.selectFile": "ファイルを選択",
"dialog.directory.action.selectFolder": "フォルダを選択",
"dialog.directory.root": "ルート",
"dialog.directory.parent": "親フォルダ",
"dialog.directory.readError": "このフォルダを読み取れません",
"dialog.server.title": "サーバー", "dialog.server.title": "サーバー",
"dialog.server.description": "このアプリが接続するOpenCodeサーバーを切り替えます。", "dialog.server.description": "このアプリが接続するOpenCodeサーバーを切り替えます。",
"dialog.server.search.placeholder": "サーバーを検索", "dialog.server.search.placeholder": "サーバーを検索",

View File

@ -280,6 +280,11 @@ export const dict = {
"dialog.fork.empty": "분기할 메시지 없음", "dialog.fork.empty": "분기할 메시지 없음",
"dialog.directory.search.placeholder": "폴더 검색", "dialog.directory.search.placeholder": "폴더 검색",
"dialog.directory.empty": "폴더 없음", "dialog.directory.empty": "폴더 없음",
"dialog.directory.action.selectFile": "파일 선택",
"dialog.directory.action.selectFolder": "폴더 선택",
"dialog.directory.root": "루트",
"dialog.directory.parent": "상위 폴더",
"dialog.directory.readError": "이 폴더를 읽을 수 없습니다",
"dialog.server.title": "서버", "dialog.server.title": "서버",
"dialog.server.description": "이 앱이 연결할 OpenCode 서버를 전환합니다.", "dialog.server.description": "이 앱이 연결할 OpenCode 서버를 전환합니다.",
"dialog.server.search.placeholder": "서버 검색", "dialog.server.search.placeholder": "서버 검색",

View File

@ -309,6 +309,11 @@ export const dict = {
"dialog.directory.search.placeholder": "Søk etter mapper", "dialog.directory.search.placeholder": "Søk etter mapper",
"dialog.directory.empty": "Ingen mapper funnet", "dialog.directory.empty": "Ingen mapper funnet",
"dialog.directory.action.selectFile": "Velg fil",
"dialog.directory.action.selectFolder": "Velg mappe",
"dialog.directory.root": "Rot",
"dialog.directory.parent": "Overordnet",
"dialog.directory.readError": "Kan ikke lese denne mappen",
"dialog.server.title": "Servere", "dialog.server.title": "Servere",
"dialog.server.description": "Bytt hvilken OpenCode-server denne appen kobler til.", "dialog.server.description": "Bytt hvilken OpenCode-server denne appen kobler til.",

View File

@ -282,6 +282,11 @@ export const dict = {
"dialog.fork.empty": "Brak wiadomości do rozwidlenia", "dialog.fork.empty": "Brak wiadomości do rozwidlenia",
"dialog.directory.search.placeholder": "Szukaj folderów", "dialog.directory.search.placeholder": "Szukaj folderów",
"dialog.directory.empty": "Nie znaleziono folderów", "dialog.directory.empty": "Nie znaleziono folderów",
"dialog.directory.action.selectFile": "Wybierz plik",
"dialog.directory.action.selectFolder": "Wybierz folder",
"dialog.directory.root": "Katalog główny",
"dialog.directory.parent": "Nadrzędny",
"dialog.directory.readError": "Nie można odczytać tego folderu",
"dialog.server.title": "Serwery", "dialog.server.title": "Serwery",
"dialog.server.description": "Przełącz serwer OpenCode, z którym łączy się ta aplikacja.", "dialog.server.description": "Przełącz serwer OpenCode, z którym łączy się ta aplikacja.",
"dialog.server.search.placeholder": "Szukaj serwerów", "dialog.server.search.placeholder": "Szukaj serwerów",

View File

@ -306,6 +306,11 @@ export const dict = {
"dialog.directory.search.placeholder": "Поиск папок", "dialog.directory.search.placeholder": "Поиск папок",
"dialog.directory.empty": "Папки не найдены", "dialog.directory.empty": "Папки не найдены",
"dialog.directory.action.selectFile": "Выбрать файл",
"dialog.directory.action.selectFolder": "Выбрать папку",
"dialog.directory.root": "Корень",
"dialog.directory.parent": "Родительская папка",
"dialog.directory.readError": "Не удалось прочитать эту папку",
"dialog.server.title": "Серверы", "dialog.server.title": "Серверы",
"dialog.server.description": "Переключите сервер OpenCode к которому подключается приложение.", "dialog.server.description": "Переключите сервер OpenCode к которому подключается приложение.",

View File

@ -306,6 +306,11 @@ export const dict = {
"dialog.directory.search.placeholder": "ค้นหาโฟลเดอร์", "dialog.directory.search.placeholder": "ค้นหาโฟลเดอร์",
"dialog.directory.empty": "ไม่พบโฟลเดอร์", "dialog.directory.empty": "ไม่พบโฟลเดอร์",
"dialog.directory.action.selectFile": "เลือกไฟล์",
"dialog.directory.action.selectFolder": "เลือกโฟลเดอร์",
"dialog.directory.root": "ราก",
"dialog.directory.parent": "โฟลเดอร์หลัก",
"dialog.directory.readError": "ไม่สามารถอ่านโฟลเดอร์นี้ได้",
"dialog.server.title": "เซิร์ฟเวอร์", "dialog.server.title": "เซิร์ฟเวอร์",
"dialog.server.description": "สลับเซิร์ฟเวอร์ OpenCode ที่แอปนี้เชื่อมต่อด้วย", "dialog.server.description": "สลับเซิร์ฟเวอร์ OpenCode ที่แอปนี้เชื่อมต่อด้วย",

View File

@ -311,6 +311,11 @@ export const dict = {
"dialog.directory.search.placeholder": "Klasör ara", "dialog.directory.search.placeholder": "Klasör ara",
"dialog.directory.empty": "Klasör bulunamadı", "dialog.directory.empty": "Klasör bulunamadı",
"dialog.directory.action.selectFile": "Dosya seç",
"dialog.directory.action.selectFolder": "Klasör seç",
"dialog.directory.root": "Kök",
"dialog.directory.parent": "Üst klasör",
"dialog.directory.readError": "Bu klasör okunamıyor",
"dialog.server.title": "Sunucular", "dialog.server.title": "Sunucular",
"dialog.server.description": "Bu uygulamanın hangi OpenCode sunucusuna bağlanacağını değiştirin.", "dialog.server.description": "Bu uygulamanın hangi OpenCode sunucusuna bağlanacağını değiştirin.",

View File

@ -313,6 +313,11 @@ export const dict = {
"dialog.directory.search.placeholder": "Пошук папок", "dialog.directory.search.placeholder": "Пошук папок",
"dialog.directory.empty": "Папок не знайдено", "dialog.directory.empty": "Папок не знайдено",
"dialog.directory.action.selectFile": "Вибрати файл",
"dialog.directory.action.selectFolder": "Вибрати папку",
"dialog.directory.root": "Корінь",
"dialog.directory.parent": "Батьківська папка",
"dialog.directory.readError": "Не вдалося прочитати цю папку",
"app.server.unreachable": "Не вдалося досягти {{server}}", "app.server.unreachable": "Не вдалося досягти {{server}}",
"app.server.retrying": "Автоматичне повторення...", "app.server.retrying": "Автоматичне повторення...",

View File

@ -326,6 +326,11 @@ export const dict = {
"dialog.directory.search.placeholder": "搜索文件夹", "dialog.directory.search.placeholder": "搜索文件夹",
"dialog.directory.empty": "未找到文件夹", "dialog.directory.empty": "未找到文件夹",
"dialog.directory.action.selectFile": "选择文件",
"dialog.directory.action.selectFolder": "选择文件夹",
"dialog.directory.root": "根目录",
"dialog.directory.parent": "上级目录",
"dialog.directory.readError": "无法读取此文件夹",
"dialog.server.title": "服务器", "dialog.server.title": "服务器",
"dialog.server.description": "切换此应用连接的 OpenCode 服务器。", "dialog.server.description": "切换此应用连接的 OpenCode 服务器。",

View File

@ -306,6 +306,11 @@ export const dict = {
"dialog.directory.search.placeholder": "搜尋資料夾", "dialog.directory.search.placeholder": "搜尋資料夾",
"dialog.directory.empty": "找不到資料夾", "dialog.directory.empty": "找不到資料夾",
"dialog.directory.action.selectFile": "選擇檔案",
"dialog.directory.action.selectFolder": "選擇資料夾",
"dialog.directory.root": "根目錄",
"dialog.directory.parent": "上層目錄",
"dialog.directory.readError": "無法讀取此資料夾",
"dialog.server.title": "伺服器", "dialog.server.title": "伺服器",
"dialog.server.description": "切換此應用程式連線的 OpenCode 伺服器。", "dialog.server.description": "切換此應用程式連線的 OpenCode 伺服器。",

View File

@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer } from "electron" import { contextBridge, ipcRenderer, webUtils } from "electron"
import type { ElectronAPI, WslServersEvent } from "./types" import type { ElectronAPI, WslServersEvent } from "./types"
import type { UpdaterState } from "@opencode-ai/app/updater" import type { UpdaterState } from "@opencode-ai/app/updater"
@ -88,6 +88,7 @@ const api: ElectronAPI = {
openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts), openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts),
readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path), readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path),
releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token), releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token),
getPathForFile: (file) => webUtils.getPathForFile(file),
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts), saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
openLink: (url) => ipcRenderer.send("open-link", url), openLink: (url) => ipcRenderer.send("open-link", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app), openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),

View File

@ -78,6 +78,7 @@ export type ElectronAPI = {
}) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null> }) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null>
readPickedFile: (token: string, path: string) => Promise<ArrayBuffer> readPickedFile: (token: string, path: string) => Promise<ArrayBuffer>
releasePickedFiles: (token: string) => Promise<void> releasePickedFiles: (token: string) => Promise<void>
getPathForFile: (file: File) => string
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null> saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void openLink: (url: string) => void
openPath: (path: string, app?: string) => Promise<void> openPath: (path: string, app?: string) => Promise<void>

View File

@ -78,6 +78,7 @@ const listenForDeepLinks = () => {
} }
const createPlatform = (): Platform => { const createPlatform = (): Platform => {
const attachmentPaths = new WeakMap<File, string>()
const os = (() => { const os = (() => {
const ua = navigator.userAgent const ua = navigator.userAgent
if (ua.includes("Mac")) return "macos" if (ua.includes("Mac")) return "macos"
@ -153,13 +154,19 @@ const createPlatform = (): Platform => {
if (!result) return if (!result) return
try { try {
for (const file of result.files) { for (const file of result.files) {
await onFile(new File([await window.api.readPickedFile(result.token, file.path)], file.name)) const selected = new File([await window.api.readPickedFile(result.token, file.path)], file.name)
attachmentPaths.set(selected, file.path)
await onFile(selected)
} }
} finally { } finally {
await window.api.releasePickedFiles(result.token) await window.api.releasePickedFiles(result.token)
} }
}, },
getPathForFile(file) {
return attachmentPaths.get(file) ?? window.api.getPathForFile(file)
},
async saveFilePickerDialog(opts) { async saveFilePickerDialog(opts) {
return window.api.saveFilePicker({ return window.api.saveFilePicker({
title: opts?.title ?? t("desktop.dialog.saveFile"), title: opts?.title ?? t("desktop.dialog.saveFile"),

View File

@ -0,0 +1,107 @@
diff --git a/dist/model/FileTreeController.js b/dist/model/FileTreeController.js
index 37c5d1ffedd5aff0258dec41b1b5cedb240aa3a6..365c42ef4c824bfd13d14f0d09226929af625c73 100644
--- a/dist/model/FileTreeController.js
+++ b/dist/model/FileTreeController.js
@@ -1052,8 +1052,8 @@ var FileTreeController = class {
this.#setExpandedPaths(expandedPaths);
return focusCandidate ?? this.#focusedPath;
}
- #emit() {
- for (const listener of this.#listeners) listener();
+ #emit(event) {
+ for (const listener of this.#listeners) listener(event);
}
#emitMutation(event) {
this.#mutationListeners.get(event.operation)?.forEach((listener) => {
@@ -1149,7 +1149,7 @@ var FileTreeController = class {
const searchFocusCandidate = this.#searchValue != null && this.#searchValue.length > 0 ? this.#refreshActiveSearchState() : this.#searchValue === "" ? this.#focusedPath : focusPathCandidate;
const shouldBuildFullProjection = this.#searchValue != null || event.operation !== "expand" && event.operation !== "collapse";
this.#rebuildVisibleProjection(searchFocusCandidate, shouldBuildFullProjection);
- this.#emit();
+ this.#emit(event);
const mutationEvent = toTreesMutationEvent(event);
if (mutationEvent != null) this.#emitMutation(mutationEvent);
});
diff --git a/dist/model/internalTypes.d.ts b/dist/model/internalTypes.d.ts
index 5b0fdd5a3b483439ae256795f3dad5a32182ab78..073c6f21edc20ce5bd44b122fe02b83de802b887 100644
--- a/dist/model/internalTypes.d.ts
+++ b/dist/model/internalTypes.d.ts
@@ -4,7 +4,10 @@ import { FileTreeCompositionOptions, FileTreePublicId, FileTreeRenderOptions, Fi
import { FileTreeController } from "./FileTreeController.js";
//#region src/model/internalTypes.d.ts
-type FileTreeControllerListener = () => void;
+type FileTreeControllerListener = (event?: {
+ operation: 'expand' | 'collapse';
+ path: string;
+}) => void;
interface FileTreeStickyRowCandidate {
row: FileTreeVisibleRow;
subtreeEndIndex: number;
diff --git a/dist/model/publicTypes.d.ts b/dist/model/publicTypes.d.ts
index 84a7e2d14f67c1aad8230f19a3426ab7403f378e..752e262e6d2d8a836931b7b83a93d89a75e6209c 100644
--- a/dist/model/publicTypes.d.ts
+++ b/dist/model/publicTypes.d.ts
@@ -167,6 +167,10 @@ type FileTreeOptionSurface = FileTreeRenderOptions & {
gitStatus?: readonly GitStatusEntry[];
id?: string;
icons?: FileTreeIcons;
+ onExpansionChange?: (change: {
+ expanded: boolean;
+ path: FileTreePublicId;
+ }) => void;
onSelectionChange?: FileTreeSelectionChangeListener;
renderRowDecoration?: FileTreeRowDecorationRenderer;
search?: boolean;
diff --git a/dist/render/FileTree.js b/dist/render/FileTree.js
index 6db15e51b833192f79b71a15febe1612a4c185d0..36a4e6ac527595849c1db0e6bbebb475b6030b7b 100644
--- a/dist/render/FileTree.js
+++ b/dist/render/FileTree.js
@@ -63,6 +63,7 @@ var FileTree = class {
#composition;
#controller;
#id;
+ #onExpansionChange;
#onSelectionChange;
#renderRowDecoration;
#renamingEnabled;
@@ -80,16 +81,18 @@ var FileTree = class {
#appliedUnsafeCSS;
#selectionVersion;
#selectionSubscription = null;
+ #expansionSubscription = null;
#wrapper;
#wroteHostItemHeight = false;
#wroteHostDensityFactor = false;
constructor(options) {
- const { composition, density, fileTreeSearchMode, gitStatus, id, initialSearchQuery, icons, itemHeight, onSearchChange, onSelectionChange, overscan, renderRowDecoration, renaming, search, searchBlurBehavior, searchFakeFocus, stickyFolders, unsafeCSS, initialVisibleRowCount,...controllerOptions } = options;
+ const { composition, density, fileTreeSearchMode, gitStatus, id, initialSearchQuery, icons, itemHeight, onExpansionChange, onSearchChange, onSelectionChange, overscan, renderRowDecoration, renaming, search, searchBlurBehavior, searchFakeFocus, stickyFolders, unsafeCSS, initialVisibleRowCount,...controllerOptions } = options;
this.#composition = composition;
this.#id = createClientId(id);
this.#gitStatusState = resolveFileTreeGitStatusState(gitStatus);
this.#icons = icons;
this.#unsafeCSS = unsafeCSS;
+ this.#onExpansionChange = onExpansionChange;
this.#onSelectionChange = onSelectionChange;
this.#renderRowDecoration = renderRowDecoration;
this.#renamingEnabled = renaming != null && renaming !== false;
@@ -114,6 +117,10 @@ var FileTree = class {
this.#selectionSubscription = this.#onSelectionChange == null ? null : this.subscribe(() => {
this.#emitSelectionChange();
});
+ this.#expansionSubscription = this.#onExpansionChange == null ? null : this.#controller.subscribe((event) => {
+ if (event?.operation !== "expand" && event?.operation !== "collapse") return;
+ this.#onExpansionChange?.({ path: event.path, expanded: event.operation === "expand" });
+ });
}
unmount() {
if (this.#wrapper != null) {
@@ -133,6 +140,8 @@ var FileTree = class {
this.unmount();
this.#selectionSubscription?.();
this.#selectionSubscription = null;
+ this.#expansionSubscription?.();
+ this.#expansionSubscription = null;
this.#controller.destroy();
}
getFileTreeContainer() {