|
|
@@ -1,17 +1,22 @@
|
|
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
|
+import { Icon } from "@opencode-ai/ui/icon"
|
|
|
import { Keybind } from "@opencode-ai/ui/keybind"
|
|
|
import { List } from "@opencode-ai/ui/list"
|
|
|
+import { base64Encode } from "@opencode-ai/util/encode"
|
|
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
|
-import { useParams } from "@solidjs/router"
|
|
|
-import { createMemo, createSignal, onCleanup, Show } from "solid-js"
|
|
|
+import { useNavigate, useParams } from "@solidjs/router"
|
|
|
+import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
|
|
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
|
|
+import { useGlobalSDK } from "@/context/global-sdk"
|
|
|
+import { useGlobalSync } from "@/context/global-sync"
|
|
|
import { useLayout } from "@/context/layout"
|
|
|
import { useFile } from "@/context/file"
|
|
|
import { useLanguage } from "@/context/language"
|
|
|
+import { decode64 } from "@/utils/base64"
|
|
|
|
|
|
-type EntryType = "command" | "file"
|
|
|
+type EntryType = "command" | "file" | "session"
|
|
|
|
|
|
type Entry = {
|
|
|
id: string
|
|
|
@@ -22,6 +27,9 @@ type Entry = {
|
|
|
category: string
|
|
|
option?: CommandOption
|
|
|
path?: string
|
|
|
+ directory?: string
|
|
|
+ sessionID?: string
|
|
|
+ archived?: number
|
|
|
}
|
|
|
|
|
|
type DialogSelectFileMode = "all" | "files"
|
|
|
@@ -33,6 +41,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|
|
const file = useFile()
|
|
|
const dialog = useDialog()
|
|
|
const params = useParams()
|
|
|
+ const navigate = useNavigate()
|
|
|
+ const globalSDK = useGlobalSDK()
|
|
|
+ const globalSync = useGlobalSync()
|
|
|
const filesOnly = () => props.mode === "files"
|
|
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
|
const tabs = createMemo(() => layout.tabs(sessionKey))
|
|
|
@@ -73,6 +84,52 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|
|
path,
|
|
|
})
|
|
|
|
|
|
+ const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
|
|
+ const project = createMemo(() => {
|
|
|
+ const directory = projectDirectory()
|
|
|
+ if (!directory) return
|
|
|
+ return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
|
|
+ })
|
|
|
+ const workspaces = createMemo(() => {
|
|
|
+ const directory = projectDirectory()
|
|
|
+ const current = project()
|
|
|
+ if (!current) return directory ? [directory] : []
|
|
|
+
|
|
|
+ const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
|
|
+ if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
|
|
+ return dirs
|
|
|
+ })
|
|
|
+ const homedir = createMemo(() => globalSync.data.path.home)
|
|
|
+ const label = (directory: string) => {
|
|
|
+ const current = project()
|
|
|
+ const kind =
|
|
|
+ current && directory === current.worktree
|
|
|
+ ? language.t("workspace.type.local")
|
|
|
+ : language.t("workspace.type.sandbox")
|
|
|
+ const [store] = globalSync.child(directory, { bootstrap: false })
|
|
|
+ const home = homedir()
|
|
|
+ const path = home ? directory.replace(home, "~") : directory
|
|
|
+ const name = store.vcs?.branch ?? getFilename(directory)
|
|
|
+ return `${kind} : ${name || path}`
|
|
|
+ }
|
|
|
+
|
|
|
+ const sessionItem = (input: {
|
|
|
+ directory: string
|
|
|
+ id: string
|
|
|
+ title: string
|
|
|
+ description: string
|
|
|
+ archived?: number
|
|
|
+ }): Entry => ({
|
|
|
+ id: `session:${input.directory}:${input.id}`,
|
|
|
+ type: "session",
|
|
|
+ title: input.title,
|
|
|
+ description: input.description,
|
|
|
+ category: language.t("command.category.session"),
|
|
|
+ directory: input.directory,
|
|
|
+ sessionID: input.id,
|
|
|
+ archived: input.archived,
|
|
|
+ })
|
|
|
+
|
|
|
const list = createMemo(() => allowed().map(commandItem))
|
|
|
|
|
|
const picks = createMemo(() => {
|
|
|
@@ -122,6 +179,68 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|
|
return out
|
|
|
}
|
|
|
|
|
|
+ const sessionToken = { value: 0 }
|
|
|
+ let sessionInflight: Promise<Entry[]> | undefined
|
|
|
+ let sessionAll: Entry[] | undefined
|
|
|
+
|
|
|
+ const sessions = (text: string) => {
|
|
|
+ const query = text.trim()
|
|
|
+ if (!query) {
|
|
|
+ sessionToken.value += 1
|
|
|
+ sessionInflight = undefined
|
|
|
+ sessionAll = undefined
|
|
|
+ return [] as Entry[]
|
|
|
+ }
|
|
|
+
|
|
|
+ if (sessionAll) return sessionAll
|
|
|
+ if (sessionInflight) return sessionInflight
|
|
|
+
|
|
|
+ const current = sessionToken.value
|
|
|
+ const dirs = workspaces()
|
|
|
+ if (dirs.length === 0) return [] as Entry[]
|
|
|
+
|
|
|
+ sessionInflight = Promise.all(
|
|
|
+ dirs.map((directory) => {
|
|
|
+ const description = label(directory)
|
|
|
+ return globalSDK.client.session
|
|
|
+ .list({ directory, roots: true })
|
|
|
+ .then((x) =>
|
|
|
+ (x.data ?? [])
|
|
|
+ .filter((s) => !!s?.id)
|
|
|
+ .map((s) => ({
|
|
|
+ id: s.id,
|
|
|
+ title: s.title ?? language.t("command.session.new"),
|
|
|
+ description,
|
|
|
+ directory,
|
|
|
+ archived: s.time?.archived,
|
|
|
+ })),
|
|
|
+ )
|
|
|
+ .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ .then((results) => {
|
|
|
+ if (sessionToken.value !== current) return [] as Entry[]
|
|
|
+ const seen = new Set<string>()
|
|
|
+ const next = results
|
|
|
+ .flat()
|
|
|
+ .filter((item) => {
|
|
|
+ const key = `${item.directory}:${item.id}`
|
|
|
+ if (seen.has(key)) return false
|
|
|
+ seen.add(key)
|
|
|
+ return true
|
|
|
+ })
|
|
|
+ .map(sessionItem)
|
|
|
+ sessionAll = next
|
|
|
+ return next
|
|
|
+ })
|
|
|
+ .catch(() => [] as Entry[])
|
|
|
+ .finally(() => {
|
|
|
+ sessionInflight = undefined
|
|
|
+ })
|
|
|
+
|
|
|
+ return sessionInflight
|
|
|
+ }
|
|
|
+
|
|
|
const items = async (text: string) => {
|
|
|
const query = text.trim()
|
|
|
setGrouped(query.length > 0)
|
|
|
@@ -146,9 +265,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|
|
const files = await file.searchFiles(query)
|
|
|
return files.map(fileItem)
|
|
|
}
|
|
|
- const files = await file.searchFiles(query)
|
|
|
+
|
|
|
+ const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
|
|
const entries = files.map(fileItem)
|
|
|
- return [...list(), ...entries]
|
|
|
+ return [...list(), ...nextSessions, ...entries]
|
|
|
}
|
|
|
|
|
|
const handleMove = (item: Entry | undefined) => {
|
|
|
@@ -178,6 +298,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|
|
return
|
|
|
}
|
|
|
|
|
|
+ if (item.type === "session") {
|
|
|
+ if (!item.directory || !item.sessionID) return
|
|
|
+ navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
if (!item.path) return
|
|
|
open(item.path)
|
|
|
}
|
|
|
@@ -202,13 +328,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|
|
items={items}
|
|
|
key={(item) => item.id}
|
|
|
filterKeys={["title", "description", "category"]}
|
|
|
- groupBy={(item) => item.category}
|
|
|
+ groupBy={grouped() ? (item) => item.category : () => ""}
|
|
|
onMove={handleMove}
|
|
|
onSelect={handleSelect}
|
|
|
>
|
|
|
{(item) => (
|
|
|
- <Show
|
|
|
- when={item.type === "command"}
|
|
|
+ <Switch
|
|
|
fallback={
|
|
|
<div class="w-full flex items-center justify-between rounded-md pl-1">
|
|
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
|
|
@@ -223,18 +348,43 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|
|
</div>
|
|
|
}
|
|
|
>
|
|
|
- <div class="w-full flex items-center justify-between gap-4">
|
|
|
- <div class="flex items-center gap-2 min-w-0">
|
|
|
- <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
|
|
|
- <Show when={item.description}>
|
|
|
- <span class="text-14-regular text-text-weak truncate">{item.description}</span>
|
|
|
+ <Match when={item.type === "command"}>
|
|
|
+ <div class="w-full flex items-center justify-between gap-4">
|
|
|
+ <div class="flex items-center gap-2 min-w-0">
|
|
|
+ <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
|
|
|
+ <Show when={item.description}>
|
|
|
+ <span class="text-14-regular text-text-weak truncate">{item.description}</span>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ <Show when={item.keybind}>
|
|
|
+ <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
|
|
|
</Show>
|
|
|
</div>
|
|
|
- <Show when={item.keybind}>
|
|
|
- <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
+ </Match>
|
|
|
+ <Match when={item.type === "session"}>
|
|
|
+ <div class="w-full flex items-center justify-between rounded-md pl-1">
|
|
|
+ <div class="flex items-center gap-x-3 grow min-w-0">
|
|
|
+ <Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
|
|
|
+ <div class="flex items-center gap-2 min-w-0">
|
|
|
+ <span
|
|
|
+ class="text-14-regular text-text-strong truncate"
|
|
|
+ classList={{ "opacity-70": !!item.archived }}
|
|
|
+ >
|
|
|
+ {item.title}
|
|
|
+ </span>
|
|
|
+ <Show when={item.description}>
|
|
|
+ <span
|
|
|
+ class="text-14-regular text-text-weak truncate"
|
|
|
+ classList={{ "opacity-70": !!item.archived }}
|
|
|
+ >
|
|
|
+ {item.description}
|
|
|
+ </span>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
)}
|
|
|
</List>
|
|
|
</Dialog>
|