experiment: better web picker using @pierre/tree (#31208)
This commit is contained in:
parent
25cb2be619
commit
88f5b9a90e
8
bun.lock
8
bun.lock
@ -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=="],
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
107
packages/app/src/components/dialog-select-directory-v2.css
Normal file
107
packages/app/src/components/dialog-select-directory-v2.css
Normal 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;
|
||||||
|
}
|
||||||
340
packages/app/src/components/dialog-select-directory-v2.tsx
Normal file
340
packages/app/src/components/dialog-select-directory-v2.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
191
packages/app/src/components/directory-picker-domain.test.ts
Normal file
191
packages/app/src/components/directory-picker-domain.test.ts
Normal 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()
|
||||||
|
})
|
||||||
331
packages/app/src/components/directory-picker-domain.ts
Normal file
331
packages/app/src/components/directory-picker-domain.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
packages/app/src/components/pierre-tree.test.ts
Normal file
23
packages/app/src/components/pierre-tree.test.ts
Normal 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()
|
||||||
|
})
|
||||||
@ -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 = () => (
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }]
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": "البحث في الخوادم",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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...",
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "サーバーを検索",
|
||||||
|
|||||||
@ -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": "서버 검색",
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 к которому подключается приложение.",
|
||||||
|
|||||||
@ -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 ที่แอปนี้เชื่อมต่อด้วย",
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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": "Автоматичне повторення...",
|
||||||
|
|||||||
@ -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 服务器。",
|
||||||
|
|||||||
@ -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 伺服器。",
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
107
patches/@pierre%2Ftrees@1.0.0-beta.4.patch
Normal file
107
patches/@pierre%2Ftrees@1.0.0-beta.4.patch
Normal 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() {
|
||||||
Loading…
Reference in New Issue
Block a user