Explorar o código

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

Luke Parker hai 2 meses
pai
achega
88f5b9a90e
Modificáronse 39 ficheiros con 1297 adicións e 211 borrados
  1. 8 0
      bun.lock
  2. 1 0
      package.json
  3. 1 0
      packages/app/package.json
  4. 107 0
      packages/app/src/components/dialog-select-directory-v2.css
  5. 340 0
      packages/app/src/components/dialog-select-directory-v2.tsx
  6. 8 203
      packages/app/src/components/dialog-select-directory.tsx
  7. 26 1
      packages/app/src/components/dialog-select-file.tsx
  8. 191 0
      packages/app/src/components/directory-picker-domain.test.ts
  9. 331 0
      packages/app/src/components/directory-picker-domain.ts
  10. 20 4
      packages/app/src/components/directory-picker.tsx
  11. 23 0
      packages/app/src/components/pierre-tree.test.ts
  12. 1 0
      packages/app/src/components/prompt-input.tsx
  13. 2 0
      packages/app/src/components/prompt-input/attachments.ts
  14. 25 0
      packages/app/src/components/prompt-input/build-request-parts.test.ts
  15. 1 1
      packages/app/src/components/prompt-input/build-request-parts.ts
  16. 3 0
      packages/app/src/context/platform.tsx
  17. 1 0
      packages/app/src/context/prompt.tsx
  18. 5 0
      packages/app/src/i18n/ar.ts
  19. 5 0
      packages/app/src/i18n/br.ts
  20. 5 0
      packages/app/src/i18n/bs.ts
  21. 5 0
      packages/app/src/i18n/da.ts
  22. 5 0
      packages/app/src/i18n/de.ts
  23. 5 0
      packages/app/src/i18n/en.ts
  24. 5 0
      packages/app/src/i18n/es.ts
  25. 5 0
      packages/app/src/i18n/fr.ts
  26. 5 0
      packages/app/src/i18n/ja.ts
  27. 5 0
      packages/app/src/i18n/ko.ts
  28. 5 0
      packages/app/src/i18n/no.ts
  29. 5 0
      packages/app/src/i18n/pl.ts
  30. 5 0
      packages/app/src/i18n/ru.ts
  31. 5 0
      packages/app/src/i18n/th.ts
  32. 5 0
      packages/app/src/i18n/tr.ts
  33. 5 0
      packages/app/src/i18n/uk.ts
  34. 5 0
      packages/app/src/i18n/zh.ts
  35. 5 0
      packages/app/src/i18n/zht.ts
  36. 2 1
      packages/desktop/src/preload/index.ts
  37. 1 0
      packages/desktop/src/preload/types.ts
  38. 8 1
      packages/desktop/src/renderer/index.tsx
  39. 107 0
      patches/@pierre%2Ftrees@1.0.0-beta.4.patch

+ 8 - 0
bun.lock

@@ -35,6 +35,7 @@
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
+        "@pierre/trees": "1.0.0-beta.4",
         "@sentry/solid": "catalog:",
         "@shikijs/transformers": "3.9.2",
         "@solid-primitives/active-element": "2.1.3",
@@ -922,6 +923,7 @@
     "pacote@21.5.0": "patches/pacote@21.5.0.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",
+    "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
   },
   "overrides": {
     "@opentui/core": "catalog:",
@@ -2126,6 +2128,8 @@
 
     "@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=="],
 
     "@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=="],
 
+    "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=="],
 
     "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=="],

+ 1 - 0
package.json

@@ -150,6 +150,7 @@
     "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.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",
+    "@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"
   }
 }

+ 1 - 0
packages/app/package.json

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

+ 107 - 0
packages/app/src/components/dialog-select-directory-v2.css

@@ -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;
+}

+ 340 - 0
packages/app/src/components/dialog-select-directory-v2.tsx

@@ -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>
+  )
+}

+ 8 - 203
packages/app/src/components/dialog-select-directory.tsx

@@ -4,12 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { List } from "@opencode-ai/ui/list"
 import type { ListRef } from "@opencode-ai/ui/list"
 import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
-import fuzzysort from "fuzzysort"
 import { createMemo, createResource, createSignal } from "solid-js"
-import { ServerSDK } from "@/context/server-sdk"
 import { useLanguage } from "@/context/language"
 import { ServerConnection } from "@/context/server"
 import { useGlobal } from "@/context/global"
+import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
 
 interface DialogSelectDirectoryProps {
   title?: string
@@ -24,89 +23,9 @@ type Row = {
   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 {
-  const full = trimTrailing(absolute)
-  const tilde = tildeOf(full, home)
+  const full = displayPickerPath(absolute, "", "")
+  const tilde = displayPickerPath(full, "~", home)
   const withSlash = (value: string) => {
     if (!value) return ""
     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) {
   const global = useGlobal()
   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,
   )
 
-  const directories = useDirectorySearch({
+  const directories = createDirectorySearch({
     sdk,
     home,
-    start,
+    base: start,
   })
 
   const recentProjects = createMemo(() => {
@@ -336,7 +141,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
           group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
         }
         ref={(r) => (list = r)}
-        onFilter={(value) => setFilter(cleanInput(value))}
+        onFilter={(value) => setFilter(cleanPickerInput(value))}
         onKeyEvent={(e, item) => {
           if (e.key !== "Tab") return
           if (e.shiftKey) return
@@ -345,7 +150,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
           e.preventDefault()
           e.stopPropagation()
 
-          const value = displayPath(item.absolute, filter(), home())
+          const value = displayPickerPath(item.absolute, filter(), home())
           list?.setFilter(value.endsWith("/") ? value : value + "/")
         }}
         onSelect={(path) => {
@@ -354,7 +159,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
         }}
       >
         {(item) => {
-          const path = displayPath(item.absolute, filter(), home())
+          const path = displayPickerPath(item.absolute, filter(), home())
           if (path === "~") {
             return (
               <div class="w-full flex items-center justify-between rounded-md">

+ 26 - 1
packages/app/src/components/dialog-select-file.tsx

@@ -7,18 +7,25 @@ import { List } from "@opencode-ai/ui/list"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
 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 { useServerSDK, type ServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
 import { useLayout } from "@/context/layout"
 import { useFile } from "@/context/file"
 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 { createSessionTabs } from "@/pages/session/helpers"
 import { decode64 } from "@/utils/base64"
 import { getRelativeTime } from "@/utils/time"
 
+const DialogSelectFileV2 = lazy(() =>
+  import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
+)
+
 type EntryType = "command" | "file" | "session"
 
 type Entry = {
@@ -264,6 +271,9 @@ function createSessionEntries(props: {
 export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
   const command = useCommand()
   const language = useLanguage()
+  const platform = usePlatform()
+  const server = useServer()
+  const settings = useSettings()
   const layout = useLayout()
   const file = useFile()
   const dialog = useDialog()
@@ -383,6 +393,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
     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 (
     <Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
       <List

+ 191 - 0
packages/app/src/components/directory-picker-domain.test.ts

@@ -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()
+})

+ 331 - 0
packages/app/src/components/directory-picker-domain.ts

@@ -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)
+  }
+}

+ 20 - 4
packages/app/src/components/directory-picker.tsx

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

+ 23 - 0
packages/app/src/components/pierre-tree.test.ts

@@ -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()
+})

+ 1 - 0
packages/app/src/components/prompt-input.tsx

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

+ 2 - 0
packages/app/src/components/prompt-input/attachments.ts

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

+ 25 - 0
packages/app/src/components/prompt-input/build-request-parts.test.ts

@@ -75,6 +75,31 @@ describe("buildRequestParts", () => {
     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", () => {
     const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
 

+ 1 - 1
packages/app/src/components/prompt-input/build-request-parts.ts

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

+ 3 - 0
packages/app/src/context/platform.tsx

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

+ 1 - 0
packages/app/src/context/prompt.tsx

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

+ 5 - 0
packages/app/src/i18n/ar.ts

@@ -281,6 +281,11 @@ export const dict = {
   "dialog.fork.empty": "لا توجد رسائل للتفرع منها",
   "dialog.directory.search.placeholder": "البحث في المجلدات",
   "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.description": "تبديل خادم OpenCode الذي يتصل به هذا التطبيق.",
   "dialog.server.search.placeholder": "البحث في الخوادم",

+ 5 - 0
packages/app/src/i18n/br.ts

@@ -281,6 +281,11 @@ export const dict = {
   "dialog.fork.empty": "Nenhuma mensagem para bifurcar",
   "dialog.directory.search.placeholder": "Buscar pastas",
   "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.description": "Trocar para qual servidor OpenCode este aplicativo se conecta.",
   "dialog.server.search.placeholder": "Buscar servidores",

+ 5 - 0
packages/app/src/i18n/bs.ts

@@ -307,6 +307,11 @@ export const dict = {
 
   "dialog.directory.search.placeholder": "Pretraži foldere",
   "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.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.",

+ 5 - 0
packages/app/src/i18n/da.ts

@@ -305,6 +305,11 @@ export const dict = {
 
   "dialog.directory.search.placeholder": "Søg mapper",
   "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.description": "Skift hvilken OpenCode-server denne app forbinder til.",

+ 5 - 0
packages/app/src/i18n/de.ts

@@ -287,6 +287,11 @@ export const dict = {
   "dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden",
   "dialog.directory.search.placeholder": "Ordner durchsuchen",
   "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.description": "Wechseln Sie den OpenCode-Server, mit dem sich diese App verbindet.",
   "dialog.server.search.placeholder": "Server durchsuchen",

+ 5 - 0
packages/app/src/i18n/en.ts

@@ -313,6 +313,11 @@ export const dict = {
 
   "dialog.directory.search.placeholder": "Search folders",
   "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.retrying": "Retrying automatically...",

+ 5 - 0
packages/app/src/i18n/es.ts

@@ -306,6 +306,11 @@ export const dict = {
 
   "dialog.directory.search.placeholder": "Buscar 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.description": "Cambiar a qué servidor de OpenCode se conecta esta app.",

+ 5 - 0
packages/app/src/i18n/fr.ts

@@ -282,6 +282,11 @@ export const dict = {
   "dialog.fork.empty": "Aucun message à partir duquel bifurquer",
   "dialog.directory.search.placeholder": "Rechercher des dossiers",
   "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.description": "Changez le serveur OpenCode auquel cette application se connecte.",
   "dialog.server.search.placeholder": "Rechercher des serveurs",

+ 5 - 0
packages/app/src/i18n/ja.ts

@@ -280,6 +280,11 @@ export const dict = {
   "dialog.fork.empty": "フォーク元のメッセージがありません",
   "dialog.directory.search.placeholder": "フォルダを検索",
   "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.description": "このアプリが接続するOpenCodeサーバーを切り替えます。",
   "dialog.server.search.placeholder": "サーバーを検索",

+ 5 - 0
packages/app/src/i18n/ko.ts

@@ -280,6 +280,11 @@ export const dict = {
   "dialog.fork.empty": "분기할 메시지 없음",
   "dialog.directory.search.placeholder": "폴더 검색",
   "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.description": "이 앱이 연결할 OpenCode 서버를 전환합니다.",
   "dialog.server.search.placeholder": "서버 검색",

+ 5 - 0
packages/app/src/i18n/no.ts

@@ -309,6 +309,11 @@ export const dict = {
 
   "dialog.directory.search.placeholder": "Søk etter mapper",
   "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.description": "Bytt hvilken OpenCode-server denne appen kobler til.",

+ 5 - 0
packages/app/src/i18n/pl.ts

@@ -282,6 +282,11 @@ export const dict = {
   "dialog.fork.empty": "Brak wiadomości do rozwidlenia",
   "dialog.directory.search.placeholder": "Szukaj 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.description": "Przełącz serwer OpenCode, z którym łączy się ta aplikacja.",
   "dialog.server.search.placeholder": "Szukaj serwerów",

+ 5 - 0
packages/app/src/i18n/ru.ts

@@ -306,6 +306,11 @@ export const dict = {
 
   "dialog.directory.search.placeholder": "Поиск папок",
   "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.description": "Переключите сервер OpenCode к которому подключается приложение.",

+ 5 - 0
packages/app/src/i18n/th.ts

@@ -306,6 +306,11 @@ export const dict = {
 
   "dialog.directory.search.placeholder": "ค้นหาโฟลเดอร์",
   "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.description": "สลับเซิร์ฟเวอร์ OpenCode ที่แอปนี้เชื่อมต่อด้วย",

+ 5 - 0
packages/app/src/i18n/tr.ts

@@ -311,6 +311,11 @@ export const dict = {
 
   "dialog.directory.search.placeholder": "Klasör ara",
   "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.description": "Bu uygulamanın hangi OpenCode sunucusuna bağlanacağını değiştirin.",

+ 5 - 0
packages/app/src/i18n/uk.ts

@@ -313,6 +313,11 @@ export const dict = {
 
   "dialog.directory.search.placeholder": "Пошук папок",
   "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.retrying": "Автоматичне повторення...",

+ 5 - 0
packages/app/src/i18n/zh.ts

@@ -326,6 +326,11 @@ export const dict = {
 
   "dialog.directory.search.placeholder": "搜索文件夹",
   "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.description": "切换此应用连接的 OpenCode 服务器。",

+ 5 - 0
packages/app/src/i18n/zht.ts

@@ -306,6 +306,11 @@ export const dict = {
 
   "dialog.directory.search.placeholder": "搜尋資料夾",
   "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.description": "切換此應用程式連線的 OpenCode 伺服器。",

+ 2 - 1
packages/desktop/src/preload/index.ts

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

+ 1 - 0
packages/desktop/src/preload/types.ts

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

+ 8 - 1
packages/desktop/src/renderer/index.tsx

@@ -78,6 +78,7 @@ const listenForDeepLinks = () => {
 }
 
 const createPlatform = (): Platform => {
+  const attachmentPaths = new WeakMap<File, string>()
   const os = (() => {
     const ua = navigator.userAgent
     if (ua.includes("Mac")) return "macos"
@@ -153,13 +154,19 @@ const createPlatform = (): Platform => {
       if (!result) return
       try {
         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 {
         await window.api.releasePickedFiles(result.token)
       }
     },
 
+    getPathForFile(file) {
+      return attachmentPaths.get(file) ?? window.api.getPathForFile(file)
+    },
+
     async saveFilePickerDialog(opts) {
       return window.api.saveFilePicker({
         title: opts?.title ?? t("desktop.dialog.saveFile"),

+ 107 - 0
patches/@pierre%2Ftrees@1.0.0-beta.4.patch

@@ -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() {