Skip to content

Commit a576fdb

Browse files
committed
feat(web): open projects
1 parent ae53f87 commit a576fdb

File tree

9 files changed

+295
-77
lines changed

9 files changed

+295
-77
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { useDialog } from "@opencode-ai/ui/context/dialog"
2+
import { Dialog } from "@opencode-ai/ui/dialog"
3+
import { FileIcon } from "@opencode-ai/ui/file-icon"
4+
import { List } from "@opencode-ai/ui/list"
5+
import { getDirectory, getFilename } from "@opencode-ai/util/path"
6+
import { createMemo } from "solid-js"
7+
import { useGlobalSDK } from "@/context/global-sdk"
8+
import { useGlobalSync } from "@/context/global-sync"
9+
10+
interface DialogSelectDirectoryProps {
11+
title?: string
12+
multiple?: boolean
13+
onSelect: (result: string | string[] | null) => void
14+
}
15+
16+
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
17+
const sync = useGlobalSync()
18+
const sdk = useGlobalSDK()
19+
const dialog = useDialog()
20+
21+
const home = createMemo(() => sync.data.path.home)
22+
const root = createMemo(() => sync.data.path.home || sync.data.path.directory)
23+
24+
function join(base: string | undefined, rel: string) {
25+
const b = (base ?? "").replace(/[\\/]+$/, "")
26+
const r = rel.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "")
27+
if (!b) return r
28+
if (!r) return b
29+
return b + "/" + r
30+
}
31+
32+
function display(rel: string) {
33+
const full = join(root(), rel)
34+
const h = home()
35+
if (!h) return full
36+
if (full === h) return "~"
37+
if (full.startsWith(h + "/") || full.startsWith(h + "\\")) {
38+
return "~" + full.slice(h.length)
39+
}
40+
return full
41+
}
42+
43+
function normalizeQuery(query: string) {
44+
const h = home()
45+
46+
if (!query) return query
47+
if (query.startsWith("~/")) return query.slice(2)
48+
49+
if (h) {
50+
const lc = query.toLowerCase()
51+
const hc = h.toLowerCase()
52+
if (lc === hc || lc.startsWith(hc + "/") || lc.startsWith(hc + "\\")) {
53+
return query.slice(h.length).replace(/^[\\/]+/, "")
54+
}
55+
}
56+
57+
return query
58+
}
59+
60+
async function fetchDirs(query: string) {
61+
const directory = root()
62+
if (!directory) return [] as string[]
63+
64+
const results = await sdk.client.find
65+
.files({ directory, query, type: "directory", limit: 50 })
66+
.then((x) => x.data ?? [])
67+
.catch(() => [])
68+
69+
return results.map((x) => x.replace(/[\\/]+$/, ""))
70+
}
71+
72+
const directories = async (filter: string) => {
73+
const query = normalizeQuery(filter.trim())
74+
return fetchDirs(query)
75+
}
76+
77+
function resolve(rel: string) {
78+
const absolute = join(root(), rel)
79+
props.onSelect(props.multiple ? [absolute] : absolute)
80+
dialog.close()
81+
}
82+
83+
return (
84+
<Dialog title={props.title ?? "Open project"}>
85+
<List
86+
search={{ placeholder: "Search folders", autofocus: true }}
87+
emptyMessage="No folders found"
88+
items={directories}
89+
key={(x) => x}
90+
onSelect={(path) => {
91+
if (!path) return
92+
resolve(path)
93+
}}
94+
>
95+
{(rel) => {
96+
const path = display(rel)
97+
return (
98+
<div class="w-full flex items-center justify-between rounded-md">
99+
<div class="flex items-center gap-x-3 grow min-w-0">
100+
<FileIcon node={{ path: rel, type: "directory" }} class="shrink-0 size-4" />
101+
<div class="flex items-center text-14-regular min-w-0">
102+
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
103+
{getDirectory(path)}
104+
</span>
105+
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
106+
</div>
107+
</div>
108+
</div>
109+
)
110+
}}
111+
</List>
112+
</Dialog>
113+
)
114+
}

packages/app/src/context/platform.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export type Platform = {
1717
/** Send a system notification (optional deep link) */
1818
notify(title: string, description?: string, href?: string): Promise<void>
1919

20-
/** Open native directory picker dialog (Tauri only) */
20+
/** Open directory picker dialog (native on Tauri, server-backed on web) */
2121
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
2222

2323
/** Open native file picker dialog (Tauri only) */

packages/app/src/pages/home.tsx

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import { base64Encode } from "@opencode-ai/util/encode"
88
import { Icon } from "@opencode-ai/ui/icon"
99
import { usePlatform } from "@/context/platform"
1010
import { DateTime } from "luxon"
11+
import { useDialog } from "@opencode-ai/ui/context/dialog"
12+
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
1113

1214
export default function Home() {
1315
const sync = useGlobalSync()
1416
const layout = useLayout()
1517
const platform = usePlatform()
18+
const dialog = useDialog()
1619
const navigate = useNavigate()
1720
const homedir = createMemo(() => sync.data.path.home)
1821

@@ -22,16 +25,27 @@ export default function Home() {
2225
}
2326

2427
async function chooseProject() {
25-
const result = await platform.openDirectoryPickerDialog?.({
26-
title: "Open project",
27-
multiple: true,
28-
})
29-
if (Array.isArray(result)) {
30-
for (const directory of result) {
31-
openProject(directory)
28+
function resolve(result: string | string[] | null) {
29+
if (Array.isArray(result)) {
30+
for (const directory of result) {
31+
openProject(directory)
32+
}
33+
} else if (result) {
34+
openProject(result)
3235
}
33-
} else if (result) {
34-
openProject(result)
36+
}
37+
38+
if (platform.openDirectoryPickerDialog) {
39+
const result = await platform.openDirectoryPickerDialog?.({
40+
title: "Open project",
41+
multiple: true,
42+
})
43+
resolve(result)
44+
} else {
45+
dialog.show(
46+
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
47+
() => resolve(null),
48+
)
3549
}
3650
}
3751

@@ -43,11 +57,9 @@ export default function Home() {
4357
<div class="mt-20 w-full flex flex-col gap-4">
4458
<div class="flex gap-2 items-center justify-between pl-3">
4559
<div class="text-14-medium text-text-strong">Recent projects</div>
46-
<Show when={platform.openDirectoryPickerDialog}>
47-
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
48-
Open project
49-
</Button>
50-
</Show>
60+
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
61+
Open project
62+
</Button>
5163
</div>
5264
<ul class="flex flex-col gap-2">
5365
<For
@@ -80,11 +92,9 @@ export default function Home() {
8092
<div class="text-12-regular text-text-weak">Get started by opening a local project</div>
8193
</div>
8294
<div />
83-
<Show when={platform.openDirectoryPickerDialog}>
84-
<Button class="px-3" onClick={chooseProject}>
85-
Open project
86-
</Button>
87-
</Show>
95+
<Button class="px-3" onClick={chooseProject}>
96+
Open project
97+
</Button>
8898
</div>
8999
</Match>
90100
</Switch>

packages/app/src/pages/layout.tsx

Lines changed: 50 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { DialogSelectProvider } from "@/components/dialog-select-provider"
5252
import { DialogEditProject } from "@/components/dialog-edit-project"
5353
import { useCommand, type CommandOption } from "@/context/command"
5454
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
55+
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
5556

5657
export default function Layout(props: ParentProps) {
5758
const [store, setStore] = createStore({
@@ -338,17 +339,13 @@ export default function Layout(props: ParentProps) {
338339
keybind: "mod+b",
339340
onSelect: () => layout.sidebar.toggle(),
340341
},
341-
...(platform.openDirectoryPickerDialog
342-
? [
343-
{
344-
id: "project.open",
345-
title: "Open project",
346-
category: "Project",
347-
keybind: "mod+o",
348-
onSelect: () => chooseProject(),
349-
},
350-
]
351-
: []),
342+
{
343+
id: "project.open",
344+
title: "Open project",
345+
category: "Project",
346+
keybind: "mod+o",
347+
onSelect: () => chooseProject(),
348+
},
352349
{
353350
id: "provider.connect",
354351
title: "Connect provider",
@@ -457,17 +454,28 @@ export default function Layout(props: ParentProps) {
457454
}
458455

459456
async function chooseProject() {
460-
const result = await platform.openDirectoryPickerDialog?.({
461-
title: "Open project",
462-
multiple: true,
463-
})
464-
if (Array.isArray(result)) {
465-
for (const directory of result) {
466-
openProject(directory, false)
457+
function resolve(result: string | string[] | null) {
458+
if (Array.isArray(result)) {
459+
for (const directory of result) {
460+
openProject(directory, false)
461+
}
462+
navigateToProject(result[0])
463+
} else if (result) {
464+
openProject(result)
467465
}
468-
navigateToProject(result[0])
469-
} else if (result) {
470-
openProject(result)
466+
}
467+
468+
if (platform.openDirectoryPickerDialog) {
469+
const result = await platform.openDirectoryPickerDialog?.({
470+
title: "Open project",
471+
multiple: true,
472+
})
473+
resolve(result)
474+
} else {
475+
dialog.show(
476+
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
477+
() => resolve(null),
478+
)
471479
}
472480
}
473481

@@ -955,30 +963,28 @@ export default function Layout(props: ParentProps) {
955963
</Tooltip>
956964
</Match>
957965
</Switch>
958-
<Show when={platform.openDirectoryPickerDialog}>
959-
<Tooltip
960-
placement="right"
961-
value={
962-
<div class="flex items-center gap-2">
963-
<span>Open project</span>
964-
<Show when={!sidebarProps.mobile}>
965-
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
966-
</Show>
967-
</div>
968-
}
969-
inactive={expanded()}
966+
<Tooltip
967+
placement="right"
968+
value={
969+
<div class="flex items-center gap-2">
970+
<span>Open project</span>
971+
<Show when={!sidebarProps.mobile}>
972+
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
973+
</Show>
974+
</div>
975+
}
976+
inactive={expanded()}
977+
>
978+
<Button
979+
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
980+
variant="ghost"
981+
size="large"
982+
icon="folder-add-left"
983+
onClick={chooseProject}
970984
>
971-
<Button
972-
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
973-
variant="ghost"
974-
size="large"
975-
icon="folder-add-left"
976-
onClick={chooseProject}
977-
>
978-
<Show when={expanded()}>Open project</Show>
979-
</Button>
980-
</Tooltip>
981-
</Show>
985+
<Show when={expanded()}>Open project</Show>
986+
</Button>
987+
</Tooltip>
982988
<Tooltip placement="right" value="Share feedback" inactive={expanded()}>
983989
<Button
984990
as={"a"}

0 commit comments

Comments
 (0)