summaryrefslogtreecommitdiffhomepage
path: root/packages/app
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 /packages/app
parentae53f876f1e201363415279826e820b2e7769f56 (diff)
downloadopencode-a576fdb5e4ecddc73dede97ff26239d90c94e72f.tar.gz
opencode-a576fdb5e4ecddc73dede97ff26239d90c94e72f.zip
feat(web): open projects
Diffstat (limited to 'packages/app')
-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
4 files changed, 194 insertions, 64 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"}