Преглед на файлове

feat(web): open projects

Adam преди 1 месец
родител
ревизия
a576fdb5e4

+ 114 - 0
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>
+  )
+}

+ 1 - 1
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) */

+ 29 - 19
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>

+ 50 - 44
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"}

+ 76 - 8
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
   }
 }

+ 11 - 2
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}`)

+ 7 - 2
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)
         },

+ 5 - 1
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" },
           ],
         },
       ],

+ 2 - 0
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"
 }