diff options
| author | Adam <[email protected]> | 2025-12-30 04:57:28 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-30 04:57:37 -0600 |
| commit | a576fdb5e4ecddc73dede97ff26239d90c94e72f (patch) | |
| tree | f44d10ff36b1ecc6aa8e8222d0a9b9bf3f4221fc /packages/app | |
| parent | ae53f876f1e201363415279826e820b2e7769f56 (diff) | |
| download | opencode-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.tsx | 114 | ||||
| -rw-r--r-- | packages/app/src/context/platform.tsx | 2 | ||||
| -rw-r--r-- | packages/app/src/pages/home.tsx | 48 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 94 |
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"} |
