summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-30 04:57:28 -0600
committerAdam <[email protected]>2025-12-30 04:57:37 -0600
commita576fdb5e4ecddc73dede97ff26239d90c94e72f (patch)
treef44d10ff36b1ecc6aa8e8222d0a9b9bf3f4221fc
parentae53f876f1e201363415279826e820b2e7769f56 (diff)
downloadopencode-a576fdb5e4ecddc73dede97ff26239d90c94e72f.tar.gz
opencode-a576fdb5e4ecddc73dede97ff26239d90c94e72f.zip
feat(web): open projects
-rw-r--r--packages/app/src/components/dialog-select-directory.tsx114
-rw-r--r--packages/app/src/context/platform.tsx2
-rw-r--r--packages/app/src/pages/home.tsx48
-rw-r--r--packages/app/src/pages/layout.tsx94
-rw-r--r--packages/opencode/src/file/index.ts84
-rw-r--r--packages/opencode/src/file/ripgrep.ts13
-rw-r--r--packages/opencode/src/server/server.ts9
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts6
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts2
9 files changed, 295 insertions, 77 deletions
diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx
new file mode 100644
index 000000000..bf4a1f9ed
--- /dev/null
+++ b/packages/app/src/components/dialog-select-directory.tsx
@@ -0,0 +1,114 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { List } from "@opencode-ai/ui/list"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { createMemo } from "solid-js"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
+
+interface DialogSelectDirectoryProps {
+ title?: string
+ multiple?: boolean
+ onSelect: (result: string | string[] | null) => void
+}
+
+export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
+ const sync = useGlobalSync()
+ const sdk = useGlobalSDK()
+ const dialog = useDialog()
+
+ const home = createMemo(() => sync.data.path.home)
+ const root = createMemo(() => sync.data.path.home || sync.data.path.directory)
+
+ function join(base: string | undefined, rel: string) {
+ const b = (base ?? "").replace(/[\\/]+$/, "")
+ const r = rel.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "")
+ if (!b) return r
+ if (!r) return b
+ return b + "/" + r
+ }
+
+ function display(rel: string) {
+ const full = join(root(), rel)
+ const h = home()
+ if (!h) return full
+ if (full === h) return "~"
+ if (full.startsWith(h + "/") || full.startsWith(h + "\\")) {
+ return "~" + full.slice(h.length)
+ }
+ return full
+ }
+
+ function normalizeQuery(query: string) {
+ const h = home()
+
+ if (!query) return query
+ if (query.startsWith("~/")) return query.slice(2)
+
+ if (h) {
+ const lc = query.toLowerCase()
+ const hc = h.toLowerCase()
+ if (lc === hc || lc.startsWith(hc + "/") || lc.startsWith(hc + "\\")) {
+ return query.slice(h.length).replace(/^[\\/]+/, "")
+ }
+ }
+
+ return query
+ }
+
+ async function fetchDirs(query: string) {
+ const directory = root()
+ if (!directory) return [] as string[]
+
+ const results = await sdk.client.find
+ .files({ directory, query, type: "directory", limit: 50 })
+ .then((x) => x.data ?? [])
+ .catch(() => [])
+
+ return results.map((x) => x.replace(/[\\/]+$/, ""))
+ }
+
+ const directories = async (filter: string) => {
+ const query = normalizeQuery(filter.trim())
+ return fetchDirs(query)
+ }
+
+ function resolve(rel: string) {
+ const absolute = join(root(), rel)
+ props.onSelect(props.multiple ? [absolute] : absolute)
+ dialog.close()
+ }
+
+ return (
+ <Dialog title={props.title ?? "Open project"}>
+ <List
+ search={{ placeholder: "Search folders", autofocus: true }}
+ emptyMessage="No folders found"
+ items={directories}
+ key={(x) => x}
+ onSelect={(path) => {
+ if (!path) return
+ resolve(path)
+ }}
+ >
+ {(rel) => {
+ const path = display(rel)
+ return (
+ <div class="w-full flex items-center justify-between rounded-md">
+ <div class="flex items-center gap-x-3 grow min-w-0">
+ <FileIcon node={{ path: rel, type: "directory" }} class="shrink-0 size-4" />
+ <div class="flex items-center text-14-regular min-w-0">
+ <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
+ {getDirectory(path)}
+ </span>
+ <span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
+ </div>
+ </div>
+ </div>
+ )
+ }}
+ </List>
+ </Dialog>
+ )
+}
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index 85afd1e1f..b41d6da1e 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -17,7 +17,7 @@ export type Platform = {
/** Send a system notification (optional deep link) */
notify(title: string, description?: string, href?: string): Promise<void>
- /** Open native directory picker dialog (Tauri only) */
+ /** Open directory picker dialog (native on Tauri, server-backed on web) */
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
/** Open native file picker dialog (Tauri only) */
diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
index 7cd2916e8..129a50320 100644
--- a/packages/app/src/pages/home.tsx
+++ b/packages/app/src/pages/home.tsx
@@ -8,11 +8,14 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { Icon } from "@opencode-ai/ui/icon"
import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { DialogSelectDirectory } from "@/components/dialog-select-directory"
export default function Home() {
const sync = useGlobalSync()
const layout = useLayout()
const platform = usePlatform()
+ const dialog = useDialog()
const navigate = useNavigate()
const homedir = createMemo(() => sync.data.path.home)
@@ -22,16 +25,27 @@ export default function Home() {
}
async function chooseProject() {
- const result = await platform.openDirectoryPickerDialog?.({
- title: "Open project",
- multiple: true,
- })
- if (Array.isArray(result)) {
- for (const directory of result) {
- openProject(directory)
+ function resolve(result: string | string[] | null) {
+ if (Array.isArray(result)) {
+ for (const directory of result) {
+ openProject(directory)
+ }
+ } else if (result) {
+ openProject(result)
}
- } else if (result) {
- openProject(result)
+ }
+
+ if (platform.openDirectoryPickerDialog) {
+ const result = await platform.openDirectoryPickerDialog?.({
+ title: "Open project",
+ multiple: true,
+ })
+ resolve(result)
+ } else {
+ dialog.show(
+ () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
+ () => resolve(null),
+ )
}
}
@@ -43,11 +57,9 @@ export default function Home() {
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">Recent projects</div>
- <Show when={platform.openDirectoryPickerDialog}>
- <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
- Open project
- </Button>
- </Show>
+ <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
+ Open project
+ </Button>
</div>
<ul class="flex flex-col gap-2">
<For
@@ -80,11 +92,9 @@ export default function Home() {
<div class="text-12-regular text-text-weak">Get started by opening a local project</div>
</div>
<div />
- <Show when={platform.openDirectoryPickerDialog}>
- <Button class="px-3" onClick={chooseProject}>
- Open project
- </Button>
- </Show>
+ <Button class="px-3" onClick={chooseProject}>
+ Open project
+ </Button>
</div>
</Match>
</Switch>
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 480c5eddf..46ea3bd4d 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -52,6 +52,7 @@ import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
+import { DialogSelectDirectory } from "@/components/dialog-select-directory"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -338,17 +339,13 @@ export default function Layout(props: ParentProps) {
keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(),
},
- ...(platform.openDirectoryPickerDialog
- ? [
- {
- id: "project.open",
- title: "Open project",
- category: "Project",
- keybind: "mod+o",
- onSelect: () => chooseProject(),
- },
- ]
- : []),
+ {
+ id: "project.open",
+ title: "Open project",
+ category: "Project",
+ keybind: "mod+o",
+ onSelect: () => chooseProject(),
+ },
{
id: "provider.connect",
title: "Connect provider",
@@ -457,17 +454,28 @@ export default function Layout(props: ParentProps) {
}
async function chooseProject() {
- const result = await platform.openDirectoryPickerDialog?.({
- title: "Open project",
- multiple: true,
- })
- if (Array.isArray(result)) {
- for (const directory of result) {
- openProject(directory, false)
+ function resolve(result: string | string[] | null) {
+ if (Array.isArray(result)) {
+ for (const directory of result) {
+ openProject(directory, false)
+ }
+ navigateToProject(result[0])
+ } else if (result) {
+ openProject(result)
}
- navigateToProject(result[0])
- } else if (result) {
- openProject(result)
+ }
+
+ if (platform.openDirectoryPickerDialog) {
+ const result = await platform.openDirectoryPickerDialog?.({
+ title: "Open project",
+ multiple: true,
+ })
+ resolve(result)
+ } else {
+ dialog.show(
+ () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
+ () => resolve(null),
+ )
}
}
@@ -955,30 +963,28 @@ export default function Layout(props: ParentProps) {
</Tooltip>
</Match>
</Switch>
- <Show when={platform.openDirectoryPickerDialog}>
- <Tooltip
- placement="right"
- value={
- <div class="flex items-center gap-2">
- <span>Open project</span>
- <Show when={!sidebarProps.mobile}>
- <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
- </Show>
- </div>
- }
- inactive={expanded()}
+ <Tooltip
+ placement="right"
+ value={
+ <div class="flex items-center gap-2">
+ <span>Open project</span>
+ <Show when={!sidebarProps.mobile}>
+ <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
+ </Show>
+ </div>
+ }
+ inactive={expanded()}
+ >
+ <Button
+ class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
+ variant="ghost"
+ size="large"
+ icon="folder-add-left"
+ onClick={chooseProject}
>
- <Button
- class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
- variant="ghost"
- size="large"
- icon="folder-add-left"
- onClick={chooseProject}
- >
- <Show when={expanded()}>Open project</Show>
- </Button>
- </Tooltip>
- </Show>
+ <Show when={expanded()}>Open project</Show>
+ </Button>
+ </Tooltip>
<Tooltip placement="right" value="Share feedback" inactive={expanded()}>
<Button
as={"a"}
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index 9462ec573..d2ff1d0b1 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -11,6 +11,7 @@ import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
+import { Global } from "../global"
export namespace File {
const log = Log.create({ service: "file" })
@@ -122,10 +123,49 @@ export namespace File {
type Entry = { files: string[]; dirs: string[] }
let cache: Entry = { files: [], dirs: [] }
let fetching = false
+
+ const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
+
const fn = async (result: Entry) => {
// Disable scanning if in root of file system
if (Instance.directory === path.parse(Instance.directory).root) return
fetching = true
+
+ if (isGlobalHome) {
+ const dirs = new Set<string>()
+ const ignore = new Set<string>()
+
+ if (process.platform === "darwin") ignore.add("Library")
+ if (process.platform === "win32") ignore.add("AppData")
+
+ const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
+ const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name)
+ const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
+
+ const top = await fs.promises
+ .readdir(Instance.directory, { withFileTypes: true })
+ .catch(() => [] as fs.Dirent[])
+
+ for (const entry of top) {
+ if (!entry.isDirectory()) continue
+ if (shouldIgnore(entry.name)) continue
+ dirs.add(entry.name + "/")
+
+ const base = path.join(Instance.directory, entry.name)
+ const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
+ for (const child of children) {
+ if (!child.isDirectory()) continue
+ if (shouldIgnoreNested(child.name)) continue
+ dirs.add(entry.name + "/" + child.name + "/")
+ }
+ }
+
+ result.dirs = Array.from(dirs).toSorted()
+ cache = result
+ fetching = false
+ return
+ }
+
const set = new Set<string>()
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
result.files.push(file)
@@ -329,15 +369,43 @@ export namespace File {
})
}
- export async function search(input: { query: string; limit?: number; dirs?: boolean }) {
- log.info("search", { query: input.query })
+ export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
+ const query = input.query.trim()
const limit = input.limit ?? 100
+ const kind = input.type ?? (input.dirs === false ? "file" : "all")
+ log.info("search", { query, kind })
+
const result = await state().then((x) => x.files())
- if (!input.query)
- return input.dirs !== false ? result.dirs.toSorted().slice(0, limit) : result.files.slice(0, limit)
- const items = input.dirs !== false ? [...result.files, ...result.dirs] : result.files
- const sorted = fuzzysort.go(input.query, items, { limit: limit }).map((r) => r.target)
- log.info("search", { query: input.query, results: sorted.length })
- return sorted
+
+ const hidden = (item: string) => {
+ const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
+ return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
+ }
+ const preferHidden = query.startsWith(".") || query.includes("/.")
+ const sortHiddenLast = (items: string[]) => {
+ if (preferHidden) return items
+ const visible: string[] = []
+ const hiddenItems: string[] = []
+ for (const item of items) {
+ const isHidden = hidden(item)
+ if (isHidden) hiddenItems.push(item)
+ if (!isHidden) visible.push(item)
+ }
+ return [...visible, ...hiddenItems]
+ }
+ if (!query) {
+ if (kind === "file") return result.files.slice(0, limit)
+ return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
+ }
+
+ const items =
+ kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
+
+ const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
+ const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
+ const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
+
+ log.info("search", { query, kind, results: output.length })
+ return output
}
}
diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts
index 22b714b85..841f5f305 100644
--- a/packages/opencode/src/file/ripgrep.ts
+++ b/packages/opencode/src/file/ripgrep.ts
@@ -205,8 +205,17 @@ export namespace Ripgrep {
return filepath
}
- export async function* files(input: { cwd: string; glob?: string[] }) {
- const args = [await filepath(), "--files", "--follow", "--hidden", "--glob=!.git/*"]
+ export async function* files(input: {
+ cwd: string
+ glob?: string[]
+ hidden?: boolean
+ follow?: boolean
+ maxDepth?: number
+ }) {
+ const args = [await filepath(), "--files", "--glob=!.git/*"]
+ if (input.follow !== false) args.push("--follow")
+ if (input.hidden !== false) args.push("--hidden")
+ if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 2306a4678..5c9a64e17 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -1801,7 +1801,7 @@ export namespace Server {
"/find/file",
describeRoute({
summary: "Find files",
- description: "Search for files by name or pattern in the project directory.",
+ description: "Search for files or directories by name or pattern in the project directory.",
operationId: "find.files",
responses: {
200: {
@@ -1819,15 +1819,20 @@ export namespace Server {
z.object({
query: z.string(),
dirs: z.enum(["true", "false"]).optional(),
+ type: z.enum(["file", "directory"]).optional(),
+ limit: z.coerce.number().int().min(1).max(200).optional(),
}),
),
async (c) => {
const query = c.req.valid("query").query
const dirs = c.req.valid("query").dirs
+ const type = c.req.valid("query").type
+ const limit = c.req.valid("query").limit
const results = await File.search({
query,
- limit: 10,
+ limit: limit ?? 10,
dirs: dirs !== "false",
+ type,
})
return c.json(results)
},
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index c01d351ba..b0610b64b 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -1829,13 +1829,15 @@ export class Find extends HeyApiClient {
/**
* Find files
*
- * Search for files by name or pattern in the project directory.
+ * Search for files or directories by name or pattern in the project directory.
*/
public files<ThrowOnError extends boolean = false>(
parameters: {
directory?: string
query: string
dirs?: "true" | "false"
+ type?: "file" | "directory"
+ limit?: number
},
options?: Options<never, ThrowOnError>,
) {
@@ -1847,6 +1849,8 @@ export class Find extends HeyApiClient {
{ in: "query", key: "directory" },
{ in: "query", key: "query" },
{ in: "query", key: "dirs" },
+ { in: "query", key: "type" },
+ { in: "query", key: "limit" },
],
},
],
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 8f4daa143..58b44fe17 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -3649,6 +3649,8 @@ export type FindFilesData = {
directory?: string
query: string
dirs?: "true" | "false"
+ type?: "file" | "directory"
+ limit?: number
}
url: "/find/file"
}