Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- packages/app/public/apple-touch-icon.png +0 -0
- packages/app/public/favicon.ico +0 -0
- packages/app/public/social-share-zen.png +0 -0
- packages/app/public/web-app-manifest-192x192.png +0 -0
- packages/app/src/components/command-palette.ts +281 -0
- packages/app/src/components/command-tooltip-keybind.test.ts +22 -0
- packages/app/src/components/command-tooltip-keybind.ts +11 -0
- packages/app/src/components/debug-bar.tsx +587 -0
- packages/app/src/components/dialog-command-palette-v2.css +220 -0
- packages/app/src/components/dialog-command-palette-v2.tsx +342 -0
- packages/app/src/components/dialog-connect-provider.stories.tsx +73 -0
- packages/app/src/components/dialog-connect-provider.tsx +1191 -0
- packages/app/src/components/dialog-custom-provider-form.ts +158 -0
- packages/app/src/components/dialog-custom-provider.test.ts +80 -0
- packages/app/src/components/dialog-custom-provider.tsx +330 -0
- packages/app/src/components/dialog-edit-project-v2.tsx +156 -0
- packages/app/src/components/dialog-edit-project.tsx +170 -0
- packages/app/src/components/dialog-fork.tsx +104 -0
- packages/app/src/components/dialog-manage-models.tsx +265 -0
- packages/app/src/components/dialog-release-notes.tsx +144 -0
- packages/app/src/components/dialog-select-directory-v2.css +107 -0
- packages/app/src/components/dialog-select-directory-v2.tsx +386 -0
- packages/app/src/components/dialog-select-directory.tsx +200 -0
- packages/app/src/components/dialog-select-file.tsx +189 -0
- packages/app/src/components/dialog-select-mcp.tsx +92 -0
- packages/app/src/components/dialog-select-model-search.test.ts +19 -0
- packages/app/src/components/dialog-select-model-search.ts +18 -0
- packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx +55 -0
- packages/app/src/components/dialog-select-model-unpaid-v2.tsx +176 -0
- packages/app/src/components/dialog-select-model-unpaid.tsx +147 -0
- packages/app/src/components/dialog-select-server.tsx +722 -0
- packages/app/src/components/dialog-settings.tsx +94 -0
- packages/app/src/components/dialog-usage-exceeded.tsx +46 -0
- packages/app/src/components/directory-picker-domain.test.ts +310 -0
- packages/app/src/components/directory-picker-domain.ts +414 -0
- packages/app/src/components/directory-picker-policy.ts +7 -0
- packages/app/src/components/directory-picker.test.ts +21 -0
- packages/app/src/components/directory-picker.tsx +45 -0
- packages/app/src/components/edit-project.ts +133 -0
- packages/app/src/components/external-link.tsx +21 -0
- packages/app/src/components/file-tree-v2-model.test.ts +74 -0
- packages/app/src/components/file-tree-v2-model.ts +107 -0
- packages/app/src/components/file-tree-v2.tsx +301 -0
- packages/app/src/components/file-tree.test.ts +80 -0
- packages/app/src/components/file-tree.tsx +509 -0
- packages/app/src/components/help-button.tsx +143 -0
- packages/app/src/components/model-tooltip.tsx +124 -0
- packages/app/src/components/pierre-tree.test.ts +23 -0
- packages/app/src/components/prompt-input-v2.tsx +589 -0
- packages/app/src/components/prompt-input.stories.tsx +223 -0
packages/app/public/apple-touch-icon.png
ADDED
|
|
packages/app/public/favicon.ico
ADDED
|
|
packages/app/public/social-share-zen.png
ADDED
|
packages/app/public/web-app-manifest-192x192.png
ADDED
|
packages/app/src/components/command-palette.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getFilename } from "@opencode-ai/core/util/path"
|
| 2 |
+
import type { Project } from "@opencode-ai/sdk/v2/client"
|
| 3 |
+
import type { SessionInfo } from "@opencode-ai/client/promise"
|
| 4 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 5 |
+
import { createMemo, onCleanup } from "solid-js"
|
| 6 |
+
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
| 7 |
+
import { useFile } from "@/context/file"
|
| 8 |
+
import { useGlobal } from "@/context/global"
|
| 9 |
+
import { useLanguage } from "@/context/language"
|
| 10 |
+
import { useLayout, type LocalProject } from "@/context/layout"
|
| 11 |
+
import { ServerConnection } from "@/context/server"
|
| 12 |
+
import { useServerSDK } from "@/context/server-sdk"
|
| 13 |
+
import { useTabs } from "@/context/tabs"
|
| 14 |
+
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
| 15 |
+
import { createSessionTabs } from "@/pages/session/helpers"
|
| 16 |
+
import { useSessionLayout } from "@/pages/session/session-layout"
|
| 17 |
+
import { normalizeSessionInfo } from "@/utils/session"
|
| 18 |
+
|
| 19 |
+
export type CommandPaletteEntry = {
|
| 20 |
+
id: string
|
| 21 |
+
type: "command" | "file" | "session"
|
| 22 |
+
title: string
|
| 23 |
+
description?: string
|
| 24 |
+
keybind?: string
|
| 25 |
+
category: string
|
| 26 |
+
option?: CommandOption
|
| 27 |
+
path?: string
|
| 28 |
+
directory?: string
|
| 29 |
+
sessionID?: string
|
| 30 |
+
server?: ServerConnection.Key
|
| 31 |
+
project?: LocalProject
|
| 32 |
+
archived?: number
|
| 33 |
+
updated?: number
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const ENTRY_LIMIT = 5
|
| 37 |
+
const COMMON_COMMAND_IDS = [
|
| 38 |
+
"session.new",
|
| 39 |
+
"workspace.new",
|
| 40 |
+
"session.previous",
|
| 41 |
+
"session.next",
|
| 42 |
+
"terminal.toggle",
|
| 43 |
+
"review.toggle",
|
| 44 |
+
] as const
|
| 45 |
+
|
| 46 |
+
export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
|
| 47 |
+
const seen = new Set<string>()
|
| 48 |
+
return items.filter((item) => {
|
| 49 |
+
if (seen.has(item.id)) return false
|
| 50 |
+
seen.add(item.id)
|
| 51 |
+
return true
|
| 52 |
+
})
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
|
| 56 |
+
return {
|
| 57 |
+
id: "file:" + path,
|
| 58 |
+
type: "file",
|
| 59 |
+
title: path,
|
| 60 |
+
category,
|
| 61 |
+
path,
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
|
| 66 |
+
const file = useFile()
|
| 67 |
+
const layout = useLayout()
|
| 68 |
+
const { tabs, view } = useSessionLayout()
|
| 69 |
+
|
| 70 |
+
return (path: string) => {
|
| 71 |
+
const value = file.tab(path)
|
| 72 |
+
void tabs().open(value)
|
| 73 |
+
void file.load(path)
|
| 74 |
+
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
| 75 |
+
layout.fileTree.setTab("all")
|
| 76 |
+
onOpenFile?.(path)
|
| 77 |
+
tabs().setActive(value)
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
| 82 |
+
const command = useCommand()
|
| 83 |
+
const global = useGlobal()
|
| 84 |
+
const language = useLanguage()
|
| 85 |
+
const file = useFile()
|
| 86 |
+
const dialog = useDialog()
|
| 87 |
+
const serverSDK = useServerSDK()()
|
| 88 |
+
const serverCtx = global.ensureServerCtx(serverSDK.server)
|
| 89 |
+
const appTabs = useTabs()
|
| 90 |
+
const { tabs: sessionTabs } = useSessionLayout()
|
| 91 |
+
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
| 92 |
+
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
| 93 |
+
const filesOnly = () => props.filesOnly?.() ?? false
|
| 94 |
+
|
| 95 |
+
const allowedCommands = createMemo(() => {
|
| 96 |
+
if (filesOnly()) return []
|
| 97 |
+
return commandPaletteOptions(command.options)
|
| 98 |
+
})
|
| 99 |
+
const commandEntries = createMemo(() => {
|
| 100 |
+
const category = language.t("palette.group.commands")
|
| 101 |
+
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
|
| 102 |
+
})
|
| 103 |
+
const preferredCommandEntries = createMemo(() => {
|
| 104 |
+
const all = allowedCommands()
|
| 105 |
+
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
| 106 |
+
const picked = all.filter((option) => order.has(option.id))
|
| 107 |
+
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
| 108 |
+
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
| 109 |
+
const category = language.t("palette.group.commands")
|
| 110 |
+
return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
const tabState = createSessionTabs({
|
| 114 |
+
tabs: sessionTabs,
|
| 115 |
+
pathFromTab: file.pathFromTab,
|
| 116 |
+
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
| 117 |
+
})
|
| 118 |
+
const recentFileEntries = createMemo(() => {
|
| 119 |
+
const all = tabState.openedTabs()
|
| 120 |
+
const active = tabState.activeFileTab()
|
| 121 |
+
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
| 122 |
+
const seen = new Set<string>()
|
| 123 |
+
const category = language.t("palette.group.files")
|
| 124 |
+
return order
|
| 125 |
+
.map((item) => file.pathFromTab(item))
|
| 126 |
+
.filter((path): path is string => {
|
| 127 |
+
if (!path || seen.has(path)) return false
|
| 128 |
+
seen.add(path)
|
| 129 |
+
return true
|
| 130 |
+
})
|
| 131 |
+
.slice(0, ENTRY_LIMIT)
|
| 132 |
+
.map((path) => createCommandPaletteFileEntry(path, category))
|
| 133 |
+
})
|
| 134 |
+
const rootFileEntries = createMemo(() => {
|
| 135 |
+
const category = language.t("palette.group.files")
|
| 136 |
+
return file.tree
|
| 137 |
+
.children("")
|
| 138 |
+
.filter((node) => node.type === "file")
|
| 139 |
+
.map((node) => node.path)
|
| 140 |
+
.sort((a, b) => a.localeCompare(b))
|
| 141 |
+
.slice(0, ENTRY_LIMIT)
|
| 142 |
+
.map((path) => createCommandPaletteFileEntry(path, category))
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
const sessions = createServerSessionEntries({
|
| 146 |
+
server: ServerConnection.key(serverSDK.server),
|
| 147 |
+
opened: serverCtx.projects.list,
|
| 148 |
+
stored: () => serverCtx.sync.data.project,
|
| 149 |
+
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
| 150 |
+
untitled: () => language.t("command.session.new"),
|
| 151 |
+
category: () => language.t("command.category.session"),
|
| 152 |
+
})
|
| 153 |
+
|
| 154 |
+
const highlight = (item: CommandPaletteEntry | undefined) => {
|
| 155 |
+
state.cleanup?.()
|
| 156 |
+
state.cleanup = undefined
|
| 157 |
+
if (item?.type !== "command") return
|
| 158 |
+
state.cleanup = item.option?.onHighlight?.()
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
const select = (item: CommandPaletteEntry | undefined) => {
|
| 162 |
+
if (!item) return
|
| 163 |
+
state.committed = true
|
| 164 |
+
state.cleanup = undefined
|
| 165 |
+
dialog.close()
|
| 166 |
+
if (item.type === "command") {
|
| 167 |
+
item.option?.onSelect?.("palette")
|
| 168 |
+
return
|
| 169 |
+
}
|
| 170 |
+
if (item.type === "session") {
|
| 171 |
+
if (!item.sessionID || !item.server) return
|
| 172 |
+
const directory = item.project?.worktree ?? item.directory
|
| 173 |
+
if (directory) {
|
| 174 |
+
serverCtx.projects.open(directory)
|
| 175 |
+
serverCtx.projects.touch(directory)
|
| 176 |
+
}
|
| 177 |
+
const tab = appTabs.addSessionTab({
|
| 178 |
+
server: item.server,
|
| 179 |
+
sessionId: item.sessionID,
|
| 180 |
+
})
|
| 181 |
+
appTabs.select(tab)
|
| 182 |
+
return
|
| 183 |
+
}
|
| 184 |
+
if (!item.path) return
|
| 185 |
+
openFile(item.path)
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
onCleanup(() => {
|
| 189 |
+
if (state.committed) return
|
| 190 |
+
state.cleanup?.()
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
return {
|
| 194 |
+
language,
|
| 195 |
+
file,
|
| 196 |
+
commandEntries,
|
| 197 |
+
preferredCommandEntries,
|
| 198 |
+
recentFileEntries,
|
| 199 |
+
rootFileEntries,
|
| 200 |
+
sessions,
|
| 201 |
+
highlight,
|
| 202 |
+
select,
|
| 203 |
+
close: () => dialog.close(),
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
| 208 |
+
return {
|
| 209 |
+
id: "command:" + option.id,
|
| 210 |
+
type: "command",
|
| 211 |
+
title: option.title,
|
| 212 |
+
description: option.description,
|
| 213 |
+
keybind: option.keybind,
|
| 214 |
+
category,
|
| 215 |
+
option,
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
export function createServerSessionEntries(props: {
|
| 220 |
+
server: ServerConnection.Key
|
| 221 |
+
opened: () => LocalProject[]
|
| 222 |
+
stored: () => Project[]
|
| 223 |
+
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
|
| 224 |
+
untitled: () => string
|
| 225 |
+
category: () => string
|
| 226 |
+
}) {
|
| 227 |
+
let abort: AbortController | undefined
|
| 228 |
+
|
| 229 |
+
onCleanup(() => abort?.abort())
|
| 230 |
+
|
| 231 |
+
return async (text: string): Promise<CommandPaletteEntry[]> => {
|
| 232 |
+
const search = text.trim()
|
| 233 |
+
if (!search) {
|
| 234 |
+
abort?.abort()
|
| 235 |
+
return []
|
| 236 |
+
}
|
| 237 |
+
abort?.abort()
|
| 238 |
+
const current = new AbortController()
|
| 239 |
+
abort = current
|
| 240 |
+
await new Promise<void>((resolve) => {
|
| 241 |
+
const timer = setTimeout(resolve, 100)
|
| 242 |
+
current.signal.addEventListener(
|
| 243 |
+
"abort",
|
| 244 |
+
() => {
|
| 245 |
+
clearTimeout(timer)
|
| 246 |
+
resolve()
|
| 247 |
+
},
|
| 248 |
+
{ once: true },
|
| 249 |
+
)
|
| 250 |
+
})
|
| 251 |
+
if (current.signal.aborted) return []
|
| 252 |
+
const opened = props.opened()
|
| 253 |
+
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
| 254 |
+
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
| 255 |
+
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
| 256 |
+
return props
|
| 257 |
+
.load(search, current.signal)
|
| 258 |
+
.then((result) =>
|
| 259 |
+
result.data
|
| 260 |
+
.map(normalizeSessionInfo)
|
| 261 |
+
.filter((session) => !session.time.archived)
|
| 262 |
+
.map((session) => {
|
| 263 |
+
const project =
|
| 264 |
+
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
| 265 |
+
return {
|
| 266 |
+
id: `session:${props.server}:${session.id}`,
|
| 267 |
+
type: "session" as const,
|
| 268 |
+
title: session.title || props.untitled(),
|
| 269 |
+
description: project ? displayName(project) : getFilename(session.directory),
|
| 270 |
+
category: props.category(),
|
| 271 |
+
directory: session.directory,
|
| 272 |
+
sessionID: session.id,
|
| 273 |
+
server: props.server,
|
| 274 |
+
project,
|
| 275 |
+
updated: session.time.updated,
|
| 276 |
+
}
|
| 277 |
+
}),
|
| 278 |
+
)
|
| 279 |
+
.catch(() => [] as CommandPaletteEntry[])
|
| 280 |
+
}
|
| 281 |
+
}
|
packages/app/src/components/command-tooltip-keybind.test.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, test } from "bun:test"
|
| 2 |
+
import { newTabTooltipKeybind, reviewTooltipKeybind } from "./command-tooltip-keybind"
|
| 3 |
+
|
| 4 |
+
describe("command tooltip keybinds", () => {
|
| 5 |
+
test("keeps localized review shortcut modifiers", () => {
|
| 6 |
+
const command = {
|
| 7 |
+
keybind: () => "Ctrl+Maj+R",
|
| 8 |
+
keybindParts: () => ["Ctrl", "Maj", "R"],
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
expect(reviewTooltipKeybind(command, (key) => key)).toEqual(["Ctrl", "Maj", "R"])
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
test("uses the configured new-tab shortcut", () => {
|
| 15 |
+
const command = {
|
| 16 |
+
keybind: () => "Alt+N",
|
| 17 |
+
keybindParts: () => ["Alt", "N"],
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
expect(newTabTooltipKeybind(command, (key) => key)).toEqual(["Alt", "N"])
|
| 21 |
+
})
|
| 22 |
+
})
|
packages/app/src/components/command-tooltip-keybind.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
type CommandKeybind = {
|
| 2 |
+
keybindParts: (id: string) => string[]
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
export function reviewTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
|
| 6 |
+
return command.keybindParts("review.toggle")
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function newTabTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
|
| 10 |
+
return command.keybindParts("tab.new")
|
| 11 |
+
}
|
packages/app/src/components/debug-bar.tsx
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useIsRouting, useLocation } from "@solidjs/router"
|
| 2 |
+
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
| 3 |
+
import { createStore } from "solid-js/store"
|
| 4 |
+
import { makeEventListener } from "@solid-primitives/event-listener"
|
| 5 |
+
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
| 6 |
+
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
| 7 |
+
import { useLanguage } from "@/context/language"
|
| 8 |
+
import { usePlatform } from "@/context/platform"
|
| 9 |
+
|
| 10 |
+
type Mem = Performance & {
|
| 11 |
+
memory?: {
|
| 12 |
+
usedJSHeapSize: number
|
| 13 |
+
jsHeapSizeLimit: number
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
type Evt = PerformanceEntry & {
|
| 18 |
+
interactionId?: number
|
| 19 |
+
processingStart?: number
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
type Shift = PerformanceEntry & {
|
| 23 |
+
hadRecentInput: boolean
|
| 24 |
+
value: number
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
type Obs = PerformanceObserverInit & {
|
| 28 |
+
durationThreshold?: number
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const span = 5000
|
| 32 |
+
|
| 33 |
+
const ms = (n?: number, d = 0) => {
|
| 34 |
+
if (n === undefined || Number.isNaN(n)) return
|
| 35 |
+
return `${n.toFixed(d)}ms`
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const time = (n?: number) => {
|
| 39 |
+
if (n === undefined || Number.isNaN(n)) return
|
| 40 |
+
return `${Math.round(n)}`
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const mb = (n?: number) => {
|
| 44 |
+
if (n === undefined || Number.isNaN(n)) return
|
| 45 |
+
const v = n / 1024 / 1024
|
| 46 |
+
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const bad = (n: number | undefined, limit: number, low = false) => {
|
| 50 |
+
if (n === undefined || Number.isNaN(n)) return false
|
| 51 |
+
return low ? n < limit : n > limit
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const session = (path: string) => path.includes("/session")
|
| 55 |
+
|
| 56 |
+
function Cell(props: {
|
| 57 |
+
bad?: boolean
|
| 58 |
+
dim?: boolean
|
| 59 |
+
inline?: boolean
|
| 60 |
+
label: string
|
| 61 |
+
tip: string
|
| 62 |
+
value: string
|
| 63 |
+
span?: 2 | 3
|
| 64 |
+
}) {
|
| 65 |
+
const content = () => (
|
| 66 |
+
<div
|
| 67 |
+
classList={{
|
| 68 |
+
"flex min-w-0 items-center": true,
|
| 69 |
+
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
|
| 70 |
+
"justify-center text-center": !props.inline,
|
| 71 |
+
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
| 72 |
+
"col-span-2": props.span === 2 && !props.inline,
|
| 73 |
+
"col-span-3": props.span === 3 && !props.inline,
|
| 74 |
+
}}
|
| 75 |
+
>
|
| 76 |
+
<div
|
| 77 |
+
classList={{
|
| 78 |
+
"flex min-w-0": true,
|
| 79 |
+
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
| 80 |
+
"flex-col items-center": !props.inline,
|
| 81 |
+
}}
|
| 82 |
+
>
|
| 83 |
+
<div
|
| 84 |
+
classList={{
|
| 85 |
+
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
| 86 |
+
}}
|
| 87 |
+
>
|
| 88 |
+
{props.label}
|
| 89 |
+
</div>
|
| 90 |
+
<div
|
| 91 |
+
classList={{
|
| 92 |
+
"uppercase leading-none font-bold tabular-nums": true,
|
| 93 |
+
"text-[11px]": !!props.inline,
|
| 94 |
+
"text-[13px] sm:text-[14px]": !props.inline,
|
| 95 |
+
"text-text-on-critical-base": !!props.bad,
|
| 96 |
+
"opacity-70": !!props.dim,
|
| 97 |
+
}}
|
| 98 |
+
>
|
| 99 |
+
{props.value}
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
if (props.inline) {
|
| 106 |
+
return (
|
| 107 |
+
<TooltipV2 value={props.tip} placement="top">
|
| 108 |
+
{content()}
|
| 109 |
+
</TooltipV2>
|
| 110 |
+
)
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
return (
|
| 114 |
+
<Tooltip value={props.tip} placement="top">
|
| 115 |
+
{content()}
|
| 116 |
+
</Tooltip>
|
| 117 |
+
)
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function ToggleCell(props: {
|
| 121 |
+
active: boolean
|
| 122 |
+
inline?: boolean
|
| 123 |
+
label: string
|
| 124 |
+
onClick: () => void
|
| 125 |
+
tip: string
|
| 126 |
+
value: string
|
| 127 |
+
}) {
|
| 128 |
+
const content = () => (
|
| 129 |
+
<button
|
| 130 |
+
type="button"
|
| 131 |
+
aria-label={`${props.label}: ${props.value}`}
|
| 132 |
+
aria-pressed={props.active}
|
| 133 |
+
classList={{
|
| 134 |
+
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
| 135 |
+
"min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
|
| 136 |
+
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
|
| 137 |
+
"bg-surface-raised-base text-text-strong": props.active,
|
| 138 |
+
}}
|
| 139 |
+
onClick={props.onClick}
|
| 140 |
+
>
|
| 141 |
+
<span
|
| 142 |
+
classList={{
|
| 143 |
+
flex: true,
|
| 144 |
+
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
| 145 |
+
"flex-col items-center": !props.inline,
|
| 146 |
+
}}
|
| 147 |
+
>
|
| 148 |
+
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
|
| 149 |
+
<span class="text-[11px] leading-none font-bold">{props.value}</span>
|
| 150 |
+
</span>
|
| 151 |
+
</button>
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
if (props.inline) {
|
| 155 |
+
return (
|
| 156 |
+
<TooltipV2 value={props.tip} placement="top">
|
| 157 |
+
{content()}
|
| 158 |
+
</TooltipV2>
|
| 159 |
+
)
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
return (
|
| 163 |
+
<Tooltip value={props.tip} placement="top">
|
| 164 |
+
{content()}
|
| 165 |
+
</Tooltip>
|
| 166 |
+
)
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
export function DebugBar(props: { inline?: boolean } = {}) {
|
| 170 |
+
const language = useLanguage()
|
| 171 |
+
const platform = usePlatform()
|
| 172 |
+
const location = useLocation()
|
| 173 |
+
const routing = useIsRouting()
|
| 174 |
+
const [state, setState] = createStore({
|
| 175 |
+
cls: undefined as number | undefined,
|
| 176 |
+
delay: undefined as number | undefined,
|
| 177 |
+
fps: undefined as number | undefined,
|
| 178 |
+
gap: undefined as number | undefined,
|
| 179 |
+
focus: false,
|
| 180 |
+
heap: {
|
| 181 |
+
limit: undefined as number | undefined,
|
| 182 |
+
used: undefined as number | undefined,
|
| 183 |
+
},
|
| 184 |
+
inp: undefined as number | undefined,
|
| 185 |
+
jank: undefined as number | undefined,
|
| 186 |
+
long: {
|
| 187 |
+
block: undefined as number | undefined,
|
| 188 |
+
count: undefined as number | undefined,
|
| 189 |
+
max: undefined as number | undefined,
|
| 190 |
+
},
|
| 191 |
+
nav: {
|
| 192 |
+
dur: undefined as number | undefined,
|
| 193 |
+
pending: false,
|
| 194 |
+
},
|
| 195 |
+
})
|
| 196 |
+
|
| 197 |
+
const na = () => language.t("debugBar.na").toUpperCase()
|
| 198 |
+
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
| 199 |
+
const heapv = () => {
|
| 200 |
+
const value = heap()
|
| 201 |
+
if (value === undefined) return na()
|
| 202 |
+
return `${Math.round(value * 100)}%`
|
| 203 |
+
}
|
| 204 |
+
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
| 205 |
+
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
| 206 |
+
const toggleFocus = async () => {
|
| 207 |
+
if (!platform.setForceFocus) return
|
| 208 |
+
const enabled = !state.focus
|
| 209 |
+
await platform.setForceFocus(enabled)
|
| 210 |
+
setState("focus", enabled)
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
onCleanup(() => {
|
| 214 |
+
if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
let prev = ""
|
| 218 |
+
let start = 0
|
| 219 |
+
let init = false
|
| 220 |
+
let one = 0
|
| 221 |
+
let two = 0
|
| 222 |
+
|
| 223 |
+
createEffect(() => {
|
| 224 |
+
const busy = routing()
|
| 225 |
+
const next = `${location.pathname}${location.search}`
|
| 226 |
+
|
| 227 |
+
if (!init) {
|
| 228 |
+
init = true
|
| 229 |
+
prev = next
|
| 230 |
+
return
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
if (busy) {
|
| 234 |
+
if (one !== 0) cancelAnimationFrame(one)
|
| 235 |
+
if (two !== 0) cancelAnimationFrame(two)
|
| 236 |
+
one = 0
|
| 237 |
+
two = 0
|
| 238 |
+
if (start !== 0) return
|
| 239 |
+
start = performance.now()
|
| 240 |
+
if (session(prev)) setState("nav", { dur: undefined, pending: true })
|
| 241 |
+
return
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
if (start === 0) {
|
| 245 |
+
prev = next
|
| 246 |
+
return
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
const at = start
|
| 250 |
+
const from = prev
|
| 251 |
+
start = 0
|
| 252 |
+
prev = next
|
| 253 |
+
|
| 254 |
+
if (!(session(from) || session(next))) return
|
| 255 |
+
|
| 256 |
+
if (one !== 0) cancelAnimationFrame(one)
|
| 257 |
+
if (two !== 0) cancelAnimationFrame(two)
|
| 258 |
+
one = requestAnimationFrame(() => {
|
| 259 |
+
one = 0
|
| 260 |
+
two = requestAnimationFrame(() => {
|
| 261 |
+
two = 0
|
| 262 |
+
setState("nav", { dur: performance.now() - at, pending: false })
|
| 263 |
+
})
|
| 264 |
+
})
|
| 265 |
+
})
|
| 266 |
+
|
| 267 |
+
onMount(() => {
|
| 268 |
+
const obs: PerformanceObserver[] = []
|
| 269 |
+
const fps: Array<{ at: number; dur: number }> = []
|
| 270 |
+
const long: Array<{ at: number; dur: number }> = []
|
| 271 |
+
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
|
| 272 |
+
let hasLong = false
|
| 273 |
+
let poll: number | undefined
|
| 274 |
+
let raf = 0
|
| 275 |
+
let last = 0
|
| 276 |
+
let snap = 0
|
| 277 |
+
|
| 278 |
+
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
|
| 279 |
+
while (list[0] && at - list[0].at > span) list.shift()
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
const syncFrame = (at: number) => {
|
| 283 |
+
trim(fps, span, at)
|
| 284 |
+
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
|
| 285 |
+
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
|
| 286 |
+
const jank = fps.filter((entry) => entry.dur > 32).length
|
| 287 |
+
batch(() => {
|
| 288 |
+
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
|
| 289 |
+
setState("gap", gap > 0 ? gap : undefined)
|
| 290 |
+
setState("jank", jank)
|
| 291 |
+
})
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
const syncLong = (at = performance.now()) => {
|
| 295 |
+
if (!hasLong) return
|
| 296 |
+
trim(long, span, at)
|
| 297 |
+
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
|
| 298 |
+
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
|
| 299 |
+
setState("long", { block, count: long.length, max })
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
const syncInp = (at = performance.now()) => {
|
| 303 |
+
for (const [key, entry] of seen) {
|
| 304 |
+
if (at - entry.at > span) seen.delete(key)
|
| 305 |
+
}
|
| 306 |
+
let delay = 0
|
| 307 |
+
let inp = 0
|
| 308 |
+
for (const entry of seen.values()) {
|
| 309 |
+
delay = Math.max(delay, entry.delay)
|
| 310 |
+
inp = Math.max(inp, entry.dur)
|
| 311 |
+
}
|
| 312 |
+
batch(() => {
|
| 313 |
+
setState("delay", delay > 0 ? delay : undefined)
|
| 314 |
+
setState("inp", inp > 0 ? inp : undefined)
|
| 315 |
+
})
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
const syncHeap = () => {
|
| 319 |
+
const mem = (performance as Mem).memory
|
| 320 |
+
if (!mem) return
|
| 321 |
+
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
const reset = () => {
|
| 325 |
+
fps.length = 0
|
| 326 |
+
long.length = 0
|
| 327 |
+
seen.clear()
|
| 328 |
+
last = 0
|
| 329 |
+
snap = 0
|
| 330 |
+
batch(() => {
|
| 331 |
+
setState("fps", undefined)
|
| 332 |
+
setState("gap", undefined)
|
| 333 |
+
setState("jank", undefined)
|
| 334 |
+
setState("delay", undefined)
|
| 335 |
+
setState("inp", undefined)
|
| 336 |
+
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
|
| 337 |
+
})
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
|
| 341 |
+
if (typeof PerformanceObserver === "undefined") return false
|
| 342 |
+
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
|
| 343 |
+
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
|
| 344 |
+
try {
|
| 345 |
+
ob.observe(init)
|
| 346 |
+
obs.push(ob)
|
| 347 |
+
return true
|
| 348 |
+
} catch {
|
| 349 |
+
ob.disconnect()
|
| 350 |
+
return false
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
if (
|
| 355 |
+
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
|
| 356 |
+
const add = entries.reduce((sum, entry) => {
|
| 357 |
+
const item = entry as Shift
|
| 358 |
+
if (item.hadRecentInput) return sum
|
| 359 |
+
return sum + item.value
|
| 360 |
+
}, 0)
|
| 361 |
+
if (add === 0) return
|
| 362 |
+
setState("cls", (value) => (value ?? 0) + add)
|
| 363 |
+
})
|
| 364 |
+
) {
|
| 365 |
+
setState("cls", 0)
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
if (
|
| 369 |
+
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
|
| 370 |
+
const at = performance.now()
|
| 371 |
+
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
|
| 372 |
+
syncLong(at)
|
| 373 |
+
})
|
| 374 |
+
) {
|
| 375 |
+
hasLong = true
|
| 376 |
+
setState("long", { block: 0, count: 0, max: 0 })
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
|
| 380 |
+
for (const raw of entries) {
|
| 381 |
+
const entry = raw as Evt
|
| 382 |
+
if (entry.duration < 16) continue
|
| 383 |
+
const key =
|
| 384 |
+
entry.interactionId && entry.interactionId > 0
|
| 385 |
+
? entry.interactionId
|
| 386 |
+
: `${entry.name}:${Math.round(entry.startTime)}`
|
| 387 |
+
const prev = seen.get(key)
|
| 388 |
+
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
|
| 389 |
+
seen.set(key, {
|
| 390 |
+
at: entry.startTime,
|
| 391 |
+
delay: Math.max(prev?.delay ?? 0, delay),
|
| 392 |
+
dur: Math.max(prev?.dur ?? 0, entry.duration),
|
| 393 |
+
})
|
| 394 |
+
if (seen.size <= 200) continue
|
| 395 |
+
const first = seen.keys().next().value
|
| 396 |
+
if (first !== undefined) seen.delete(first)
|
| 397 |
+
}
|
| 398 |
+
syncInp()
|
| 399 |
+
})
|
| 400 |
+
|
| 401 |
+
const loop = (at: number) => {
|
| 402 |
+
if (document.visibilityState !== "visible") {
|
| 403 |
+
raf = 0
|
| 404 |
+
return
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
if (last === 0) {
|
| 408 |
+
last = at
|
| 409 |
+
raf = requestAnimationFrame(loop)
|
| 410 |
+
return
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
fps.push({ at, dur: at - last })
|
| 414 |
+
last = at
|
| 415 |
+
|
| 416 |
+
if (at - snap >= 250) {
|
| 417 |
+
snap = at
|
| 418 |
+
syncFrame(at)
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
raf = requestAnimationFrame(loop)
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
const stop = () => {
|
| 425 |
+
if (raf !== 0) cancelAnimationFrame(raf)
|
| 426 |
+
raf = 0
|
| 427 |
+
if (poll === undefined) return
|
| 428 |
+
clearInterval(poll)
|
| 429 |
+
poll = undefined
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
const start = () => {
|
| 433 |
+
if (document.visibilityState !== "visible") return
|
| 434 |
+
if (poll === undefined) {
|
| 435 |
+
poll = window.setInterval(() => {
|
| 436 |
+
syncLong()
|
| 437 |
+
syncInp()
|
| 438 |
+
syncHeap()
|
| 439 |
+
}, 1000)
|
| 440 |
+
}
|
| 441 |
+
if (raf !== 0) return
|
| 442 |
+
raf = requestAnimationFrame(loop)
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
const vis = () => {
|
| 446 |
+
if (document.visibilityState !== "visible") {
|
| 447 |
+
stop()
|
| 448 |
+
return
|
| 449 |
+
}
|
| 450 |
+
reset()
|
| 451 |
+
start()
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
syncHeap()
|
| 455 |
+
start()
|
| 456 |
+
makeEventListener(document, "visibilitychange", vis)
|
| 457 |
+
|
| 458 |
+
onCleanup(() => {
|
| 459 |
+
if (one !== 0) cancelAnimationFrame(one)
|
| 460 |
+
if (two !== 0) cancelAnimationFrame(two)
|
| 461 |
+
stop()
|
| 462 |
+
for (const ob of obs) ob.disconnect()
|
| 463 |
+
})
|
| 464 |
+
})
|
| 465 |
+
|
| 466 |
+
return (
|
| 467 |
+
<aside
|
| 468 |
+
aria-label={language.t("debugBar.ariaLabel")}
|
| 469 |
+
classList={{
|
| 470 |
+
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
|
| 471 |
+
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
|
| 472 |
+
"fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]":
|
| 473 |
+
!props.inline,
|
| 474 |
+
}}
|
| 475 |
+
>
|
| 476 |
+
<div
|
| 477 |
+
classList={{
|
| 478 |
+
"font-mono": true,
|
| 479 |
+
"gap-[9px]": !!props.inline,
|
| 480 |
+
"gap-px": !props.inline,
|
| 481 |
+
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
|
| 482 |
+
"grid-cols-4": !props.inline,
|
| 483 |
+
grid: !props.inline,
|
| 484 |
+
}}
|
| 485 |
+
>
|
| 486 |
+
<Cell
|
| 487 |
+
label={language.t("debugBar.nav.label")}
|
| 488 |
+
tip={language.t("debugBar.nav.tip")}
|
| 489 |
+
value={navv()}
|
| 490 |
+
bad={bad(state.nav.dur, 400)}
|
| 491 |
+
dim={state.nav.dur === undefined && !state.nav.pending}
|
| 492 |
+
inline={props.inline}
|
| 493 |
+
/>
|
| 494 |
+
<Cell
|
| 495 |
+
label={language.t("debugBar.fps.label")}
|
| 496 |
+
tip={language.t("debugBar.fps.tip")}
|
| 497 |
+
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
| 498 |
+
bad={bad(state.fps, 50, true)}
|
| 499 |
+
dim={state.fps === undefined}
|
| 500 |
+
inline={props.inline}
|
| 501 |
+
/>
|
| 502 |
+
<Cell
|
| 503 |
+
label={language.t("debugBar.frame.label")}
|
| 504 |
+
tip={language.t("debugBar.frame.tip")}
|
| 505 |
+
value={time(state.gap) ?? na()}
|
| 506 |
+
bad={bad(state.gap, 50)}
|
| 507 |
+
dim={state.gap === undefined}
|
| 508 |
+
inline={props.inline}
|
| 509 |
+
/>
|
| 510 |
+
<Cell
|
| 511 |
+
label={language.t("debugBar.jank.label")}
|
| 512 |
+
tip={language.t("debugBar.jank.tip")}
|
| 513 |
+
value={state.jank === undefined ? na() : `${state.jank}`}
|
| 514 |
+
bad={bad(state.jank, 8)}
|
| 515 |
+
dim={state.jank === undefined}
|
| 516 |
+
inline={props.inline}
|
| 517 |
+
/>
|
| 518 |
+
<Cell
|
| 519 |
+
label={language.t("debugBar.long.label")}
|
| 520 |
+
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
|
| 521 |
+
value={longv()}
|
| 522 |
+
bad={bad(state.long.block, 200)}
|
| 523 |
+
dim={state.long.count === undefined}
|
| 524 |
+
inline={props.inline}
|
| 525 |
+
/>
|
| 526 |
+
<Cell
|
| 527 |
+
label={language.t("debugBar.delay.label")}
|
| 528 |
+
tip={language.t("debugBar.delay.tip")}
|
| 529 |
+
value={time(state.delay) ?? na()}
|
| 530 |
+
bad={bad(state.delay, 100)}
|
| 531 |
+
dim={state.delay === undefined}
|
| 532 |
+
inline={props.inline}
|
| 533 |
+
/>
|
| 534 |
+
<Cell
|
| 535 |
+
label={language.t("debugBar.inp.label")}
|
| 536 |
+
tip={language.t("debugBar.inp.tip")}
|
| 537 |
+
value={time(state.inp) ?? na()}
|
| 538 |
+
bad={bad(state.inp, 200)}
|
| 539 |
+
dim={state.inp === undefined}
|
| 540 |
+
inline={props.inline}
|
| 541 |
+
/>
|
| 542 |
+
<Cell
|
| 543 |
+
label={language.t("debugBar.cls.label")}
|
| 544 |
+
tip={language.t("debugBar.cls.tip")}
|
| 545 |
+
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
| 546 |
+
bad={bad(state.cls, 0.1)}
|
| 547 |
+
dim={state.cls === undefined}
|
| 548 |
+
inline={props.inline}
|
| 549 |
+
/>
|
| 550 |
+
<Cell
|
| 551 |
+
label={language.t("debugBar.mem.label")}
|
| 552 |
+
tip={
|
| 553 |
+
state.heap.used === undefined
|
| 554 |
+
? language.t("debugBar.mem.tipUnavailable")
|
| 555 |
+
: language.t("debugBar.mem.tip", {
|
| 556 |
+
used: mb(state.heap.used) ?? na(),
|
| 557 |
+
limit: mb(state.heap.limit) ?? na(),
|
| 558 |
+
})
|
| 559 |
+
}
|
| 560 |
+
value={heapv()}
|
| 561 |
+
bad={bad(heap(), 0.8)}
|
| 562 |
+
dim={state.heap.used === undefined}
|
| 563 |
+
inline={props.inline}
|
| 564 |
+
span={platform.setForceFocus ? 2 : 3}
|
| 565 |
+
/>
|
| 566 |
+
<ToggleCell
|
| 567 |
+
active={language.direction() === "rtl"}
|
| 568 |
+
inline={props.inline}
|
| 569 |
+
label={language.t("debugBar.direction.label")}
|
| 570 |
+
tip={language.t("debugBar.direction.tip")}
|
| 571 |
+
value={language.t(`debugBar.direction.${language.direction()}`)}
|
| 572 |
+
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
| 573 |
+
/>
|
| 574 |
+
{platform.setForceFocus && (
|
| 575 |
+
<ToggleCell
|
| 576 |
+
active={state.focus}
|
| 577 |
+
inline={props.inline}
|
| 578 |
+
label={language.t("debugBar.focus.label")}
|
| 579 |
+
tip={language.t("debugBar.focus.tip")}
|
| 580 |
+
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
| 581 |
+
onClick={() => void toggleFocus()}
|
| 582 |
+
/>
|
| 583 |
+
)}
|
| 584 |
+
</div>
|
| 585 |
+
</aside>
|
| 586 |
+
)
|
| 587 |
+
}
|
packages/app/src/components/dialog-command-palette-v2.css
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.command-palette-v2 {
|
| 2 |
+
overflow: hidden;
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
|
| 6 |
+
top stays put while the content-driven height grows and shrinks. */
|
| 7 |
+
[data-component="dialog-v2"]:has(.command-palette-v2) {
|
| 8 |
+
align-items: flex-start;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
|
| 12 |
+
width: min(calc(100vw - 24px), 640px);
|
| 13 |
+
height: auto;
|
| 14 |
+
min-height: 280px;
|
| 15 |
+
max-height: min(calc(100vh - 96px), 480px);
|
| 16 |
+
margin-top: max(48px, calc((100vh - 480px) / 2));
|
| 17 |
+
border-radius: 12px;
|
| 18 |
+
background: var(--v2-background-bg-base);
|
| 19 |
+
box-shadow: var(--v2-elevation-floating);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.command-palette-v2-body {
|
| 23 |
+
display: flex;
|
| 24 |
+
min-height: 0;
|
| 25 |
+
flex: 1;
|
| 26 |
+
flex-direction: column;
|
| 27 |
+
gap: 0;
|
| 28 |
+
padding: 0;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.command-palette-v2-search {
|
| 32 |
+
flex-shrink: 0;
|
| 33 |
+
padding: 6px;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.command-palette-v2-search [data-component="text-input-v2"] {
|
| 37 |
+
width: 100%;
|
| 38 |
+
height: 36px;
|
| 39 |
+
border-radius: 6px;
|
| 40 |
+
outline: 0;
|
| 41 |
+
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
|
| 42 |
+
box-shadow: none;
|
| 43 |
+
transition:
|
| 44 |
+
background-color 120ms ease-in-out,
|
| 45 |
+
box-shadow 120ms ease-in-out;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
|
| 49 |
+
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
|
| 50 |
+
background: var(--v2-background-bg-layer-02);
|
| 51 |
+
box-shadow: none;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
|
| 55 |
+
border-radius: 6px;
|
| 56 |
+
background: transparent;
|
| 57 |
+
gap: 8px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
|
| 61 |
+
padding-left: 12px;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
|
| 65 |
+
border-radius: 6px;
|
| 66 |
+
background: transparent;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.command-palette-v2-scroll {
|
| 70 |
+
min-height: 0;
|
| 71 |
+
flex: 1;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.command-palette-v2-results {
|
| 75 |
+
display: flex;
|
| 76 |
+
flex-direction: column;
|
| 77 |
+
gap: 16px;
|
| 78 |
+
padding: 6px 6px 8px;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.command-palette-v2-group {
|
| 82 |
+
display: flex;
|
| 83 |
+
flex-direction: column;
|
| 84 |
+
gap: 1px;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.command-palette-v2-group-title {
|
| 88 |
+
margin: 6px 0;
|
| 89 |
+
padding: 0 12px;
|
| 90 |
+
color: var(--v2-text-text-muted);
|
| 91 |
+
font-size: 13px;
|
| 92 |
+
font-weight: 440;
|
| 93 |
+
line-height: 16px;
|
| 94 |
+
letter-spacing: -0.04px;
|
| 95 |
+
user-select: none;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.command-palette-v2-row {
|
| 99 |
+
display: flex;
|
| 100 |
+
width: 100%;
|
| 101 |
+
height: 36px;
|
| 102 |
+
flex-shrink: 0;
|
| 103 |
+
align-items: center;
|
| 104 |
+
justify-content: space-between;
|
| 105 |
+
gap: 8px;
|
| 106 |
+
padding: 0 12px;
|
| 107 |
+
border: 0;
|
| 108 |
+
border-radius: 6px;
|
| 109 |
+
background: transparent;
|
| 110 |
+
color: var(--v2-text-text-base);
|
| 111 |
+
text-align: left;
|
| 112 |
+
cursor: default;
|
| 113 |
+
scroll-margin: 6px 0;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.command-palette-v2-row[data-active] {
|
| 117 |
+
background: var(--v2-overlay-simple-overlay-hover);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.command-palette-v2-row:focus-visible {
|
| 121 |
+
background: var(--v2-overlay-simple-overlay-hover);
|
| 122 |
+
outline: none;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.command-palette-v2-row-main {
|
| 126 |
+
display: flex;
|
| 127 |
+
min-width: 0;
|
| 128 |
+
flex: 1;
|
| 129 |
+
align-items: center;
|
| 130 |
+
gap: 8px;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.command-palette-v2-row-icon {
|
| 134 |
+
flex-shrink: 0;
|
| 135 |
+
color: var(--v2-icon-icon-muted);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.command-palette-v2-row-text {
|
| 139 |
+
display: flex;
|
| 140 |
+
min-width: 0;
|
| 141 |
+
align-items: center;
|
| 142 |
+
gap: 6px;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.command-palette-v2-title {
|
| 146 |
+
min-width: 0;
|
| 147 |
+
overflow: hidden;
|
| 148 |
+
color: var(--v2-text-text-base);
|
| 149 |
+
font-size: 13px;
|
| 150 |
+
font-weight: 530;
|
| 151 |
+
line-height: 16px;
|
| 152 |
+
letter-spacing: -0.04px;
|
| 153 |
+
text-overflow: ellipsis;
|
| 154 |
+
white-space: nowrap;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.command-palette-v2-description,
|
| 158 |
+
.command-palette-v2-meta {
|
| 159 |
+
min-width: 0;
|
| 160 |
+
overflow: hidden;
|
| 161 |
+
color: var(--v2-text-text-muted);
|
| 162 |
+
font-size: 13px;
|
| 163 |
+
font-weight: 440;
|
| 164 |
+
line-height: 16px;
|
| 165 |
+
letter-spacing: -0.04px;
|
| 166 |
+
text-overflow: ellipsis;
|
| 167 |
+
white-space: nowrap;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.command-palette-v2-meta {
|
| 171 |
+
flex-shrink: 0;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.command-palette-v2-file-path {
|
| 175 |
+
display: flex;
|
| 176 |
+
min-width: 0;
|
| 177 |
+
align-items: baseline;
|
| 178 |
+
font-size: 13px;
|
| 179 |
+
line-height: 16px;
|
| 180 |
+
letter-spacing: -0.04px;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.command-palette-v2-file-dir {
|
| 184 |
+
min-width: 0;
|
| 185 |
+
overflow: hidden;
|
| 186 |
+
color: var(--v2-text-text-muted);
|
| 187 |
+
font-weight: 440;
|
| 188 |
+
text-overflow: ellipsis;
|
| 189 |
+
white-space: nowrap;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.command-palette-v2-file-name {
|
| 193 |
+
flex-shrink: 0;
|
| 194 |
+
color: var(--v2-text-text-base);
|
| 195 |
+
font-weight: 530;
|
| 196 |
+
white-space: nowrap;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.command-palette-v2-state {
|
| 200 |
+
display: grid;
|
| 201 |
+
min-height: 120px;
|
| 202 |
+
place-items: center;
|
| 203 |
+
color: var(--v2-text-text-muted);
|
| 204 |
+
font-size: 13px;
|
| 205 |
+
font-weight: 440;
|
| 206 |
+
line-height: 16px;
|
| 207 |
+
letter-spacing: -0.04px;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
@media (max-width: 640px) {
|
| 211 |
+
.command-palette-v2-row-text {
|
| 212 |
+
flex-direction: column;
|
| 213 |
+
align-items: flex-start;
|
| 214 |
+
gap: 1px;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.command-palette-v2-description {
|
| 218 |
+
max-width: 100%;
|
| 219 |
+
}
|
| 220 |
+
}
|
packages/app/src/components/dialog-command-palette-v2.tsx
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
| 2 |
+
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
| 3 |
+
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
| 4 |
+
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
|
| 5 |
+
import { Icon } from "@opencode-ai/ui/v2/icon"
|
| 6 |
+
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
| 7 |
+
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
| 8 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 9 |
+
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
| 10 |
+
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
|
| 11 |
+
import { useGlobal } from "@/context/global"
|
| 12 |
+
import { useLanguage } from "@/context/language"
|
| 13 |
+
import { ServerConnection } from "@/context/server"
|
| 14 |
+
import { useTabs } from "@/context/tabs"
|
| 15 |
+
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
| 16 |
+
import { getRelativeTime } from "@/utils/time"
|
| 17 |
+
import {
|
| 18 |
+
createCommandPaletteCommandEntry,
|
| 19 |
+
createCommandPaletteFileEntry,
|
| 20 |
+
createCommandPaletteModel,
|
| 21 |
+
createServerSessionEntries,
|
| 22 |
+
uniqueCommandPaletteEntries,
|
| 23 |
+
type CommandPaletteEntry,
|
| 24 |
+
} from "./command-palette"
|
| 25 |
+
import "./dialog-command-palette-v2.css"
|
| 26 |
+
|
| 27 |
+
function groups(entries: CommandPaletteEntry[]) {
|
| 28 |
+
const map = new Map<string, CommandPaletteEntry[]>()
|
| 29 |
+
for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
|
| 30 |
+
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function matchesEntry(entry: CommandPaletteEntry, query: string) {
|
| 34 |
+
const value = query.toLowerCase()
|
| 35 |
+
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
| 39 |
+
const palette = createCommandPaletteModel(props)
|
| 40 |
+
const loadItems = async (text: string) => {
|
| 41 |
+
const q = text.trim()
|
| 42 |
+
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
| 43 |
+
|
| 44 |
+
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
|
| 45 |
+
const category = palette.language.t("palette.group.files")
|
| 46 |
+
return [
|
| 47 |
+
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
| 48 |
+
...nextSessions,
|
| 49 |
+
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
| 50 |
+
]
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<CommandPaletteView
|
| 55 |
+
placeholder={palette.language.t("palette.search.placeholder")}
|
| 56 |
+
loadItems={loadItems}
|
| 57 |
+
highlight={palette.highlight}
|
| 58 |
+
select={palette.select}
|
| 59 |
+
close={palette.close}
|
| 60 |
+
/>
|
| 61 |
+
)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export function DialogHomeCommandPaletteV2(props: {
|
| 65 |
+
server: ServerConnection.Any
|
| 66 |
+
onSelectSession: (entry: CommandPaletteEntry) => void
|
| 67 |
+
}) {
|
| 68 |
+
const command = useCommand()
|
| 69 |
+
const dialog = useDialog()
|
| 70 |
+
const global = useGlobal()
|
| 71 |
+
const language = useLanguage()
|
| 72 |
+
const serverCtx = global.ensureServerCtx(props.server)
|
| 73 |
+
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
| 74 |
+
const commandEntries = createMemo(() => {
|
| 75 |
+
const category = language.t("palette.group.commands")
|
| 76 |
+
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
|
| 77 |
+
})
|
| 78 |
+
const sessions = createServerSessionEntries({
|
| 79 |
+
server: ServerConnection.key(props.server),
|
| 80 |
+
opened: serverCtx.projects.list,
|
| 81 |
+
stored: () => serverCtx.sync.data.project,
|
| 82 |
+
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
| 83 |
+
untitled: () => language.t("command.session.new"),
|
| 84 |
+
category: () => language.t("command.category.session"),
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
const highlight = (item: CommandPaletteEntry | undefined) => {
|
| 88 |
+
state.cleanup?.()
|
| 89 |
+
state.cleanup = undefined
|
| 90 |
+
if (item?.type !== "command") return
|
| 91 |
+
state.cleanup = item.option?.onHighlight?.()
|
| 92 |
+
}
|
| 93 |
+
const select = (item: CommandPaletteEntry | undefined) => {
|
| 94 |
+
if (!item) return
|
| 95 |
+
state.committed = true
|
| 96 |
+
state.cleanup = undefined
|
| 97 |
+
dialog.close()
|
| 98 |
+
if (item.type === "command") {
|
| 99 |
+
item.option?.onSelect?.("palette")
|
| 100 |
+
return
|
| 101 |
+
}
|
| 102 |
+
if (item.type === "session") props.onSelectSession(item)
|
| 103 |
+
}
|
| 104 |
+
const loadItems = async (text: string) => {
|
| 105 |
+
const query = text.trim()
|
| 106 |
+
if (!query) return commandEntries().slice(0, 5)
|
| 107 |
+
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
onCleanup(() => {
|
| 111 |
+
if (state.committed) return
|
| 112 |
+
state.cleanup?.()
|
| 113 |
+
})
|
| 114 |
+
|
| 115 |
+
return (
|
| 116 |
+
<CommandPaletteView
|
| 117 |
+
placeholder={language.t("palette.search.placeholder.home")}
|
| 118 |
+
loadItems={loadItems}
|
| 119 |
+
highlight={highlight}
|
| 120 |
+
select={select}
|
| 121 |
+
close={() => dialog.close()}
|
| 122 |
+
/>
|
| 123 |
+
)
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function CommandPaletteView(props: {
|
| 127 |
+
placeholder: string
|
| 128 |
+
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
|
| 129 |
+
highlight: (item: CommandPaletteEntry | undefined) => void
|
| 130 |
+
select: (item: CommandPaletteEntry | undefined) => void
|
| 131 |
+
close: () => void
|
| 132 |
+
}) {
|
| 133 |
+
const language = useLanguage()
|
| 134 |
+
const tabs = useTabs()
|
| 135 |
+
const [query, setQuery] = createSignal("")
|
| 136 |
+
const [active, setActive] = createSignal(0)
|
| 137 |
+
|
| 138 |
+
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
| 139 |
+
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
| 140 |
+
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
| 141 |
+
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
| 142 |
+
const activeEntry = createMemo(() => visibleEntries()[active()])
|
| 143 |
+
const openSessions = createMemo(
|
| 144 |
+
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
createEffect(() => {
|
| 148 |
+
query()
|
| 149 |
+
visibleEntries()
|
| 150 |
+
setActive(0)
|
| 151 |
+
})
|
| 152 |
+
|
| 153 |
+
createEffect(() => {
|
| 154 |
+
props.highlight(activeEntry())
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
let resultsRef: HTMLDivElement | undefined
|
| 158 |
+
|
| 159 |
+
const move = (delta: -1 | 1) => {
|
| 160 |
+
const count = visibleEntries().length
|
| 161 |
+
if (count === 0) return
|
| 162 |
+
setActive((index) => (index + delta + count) % count)
|
| 163 |
+
requestAnimationFrame(() => {
|
| 164 |
+
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
|
| 165 |
+
})
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const handleKeyDown = (event: KeyboardEvent) => {
|
| 169 |
+
if (event.key === "ArrowDown") {
|
| 170 |
+
event.preventDefault()
|
| 171 |
+
move(1)
|
| 172 |
+
return
|
| 173 |
+
}
|
| 174 |
+
if (event.key === "ArrowUp") {
|
| 175 |
+
event.preventDefault()
|
| 176 |
+
move(-1)
|
| 177 |
+
return
|
| 178 |
+
}
|
| 179 |
+
if (event.key === "Enter") {
|
| 180 |
+
event.preventDefault()
|
| 181 |
+
props.select(activeEntry())
|
| 182 |
+
return
|
| 183 |
+
}
|
| 184 |
+
if (event.key === "Escape") {
|
| 185 |
+
event.preventDefault()
|
| 186 |
+
props.close()
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
return (
|
| 191 |
+
<Dialog class="command-palette-v2" size="large">
|
| 192 |
+
<DialogBody class="command-palette-v2-body">
|
| 193 |
+
<div class="command-palette-v2-search">
|
| 194 |
+
<TextInputV2
|
| 195 |
+
value={query()}
|
| 196 |
+
autofocus
|
| 197 |
+
autocomplete="off"
|
| 198 |
+
spellcheck={false}
|
| 199 |
+
appearance="large"
|
| 200 |
+
placeholder={props.placeholder}
|
| 201 |
+
leadingIcon={<Icon name="magnifying-glass" />}
|
| 202 |
+
onInput={(event) => setQuery(event.currentTarget.value)}
|
| 203 |
+
onKeyDown={handleKeyDown}
|
| 204 |
+
/>
|
| 205 |
+
</div>
|
| 206 |
+
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
|
| 207 |
+
<div class="command-palette-v2-results" role="listbox">
|
| 208 |
+
<Show
|
| 209 |
+
when={visibleEntries().length > 0}
|
| 210 |
+
fallback={
|
| 211 |
+
<div class="command-palette-v2-state">
|
| 212 |
+
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
|
| 213 |
+
</div>
|
| 214 |
+
}
|
| 215 |
+
>
|
| 216 |
+
<For each={groupedEntries()}>
|
| 217 |
+
{(group) => (
|
| 218 |
+
<div class="command-palette-v2-group">
|
| 219 |
+
<Show when={group.category}>
|
| 220 |
+
<div class="command-palette-v2-group-title">{group.category}</div>
|
| 221 |
+
</Show>
|
| 222 |
+
<For each={group.entries}>
|
| 223 |
+
{(item) => (
|
| 224 |
+
<PaletteRow
|
| 225 |
+
item={item}
|
| 226 |
+
active={activeEntry()?.id === item.id}
|
| 227 |
+
language={language}
|
| 228 |
+
sessionOpen={
|
| 229 |
+
item.server && item.sessionID
|
| 230 |
+
? openSessions().has(`${item.server}\0${item.sessionID}`)
|
| 231 |
+
: false
|
| 232 |
+
}
|
| 233 |
+
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
| 234 |
+
onSelect={() => props.select(item)}
|
| 235 |
+
/>
|
| 236 |
+
)}
|
| 237 |
+
</For>
|
| 238 |
+
</div>
|
| 239 |
+
)}
|
| 240 |
+
</For>
|
| 241 |
+
</Show>
|
| 242 |
+
</div>
|
| 243 |
+
</ScrollView>
|
| 244 |
+
</DialogBody>
|
| 245 |
+
</Dialog>
|
| 246 |
+
)
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
function PaletteRow(props: {
|
| 250 |
+
item: CommandPaletteEntry
|
| 251 |
+
active: boolean
|
| 252 |
+
language: ReturnType<typeof useLanguage>
|
| 253 |
+
sessionOpen: boolean
|
| 254 |
+
onActive: () => void
|
| 255 |
+
onSelect: () => void
|
| 256 |
+
}) {
|
| 257 |
+
const session = () =>
|
| 258 |
+
props.item.server && props.item.directory && props.item.sessionID
|
| 259 |
+
? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
|
| 260 |
+
: undefined
|
| 261 |
+
|
| 262 |
+
return (
|
| 263 |
+
<button
|
| 264 |
+
type="button"
|
| 265 |
+
class="command-palette-v2-row group"
|
| 266 |
+
role="option"
|
| 267 |
+
aria-selected={props.active}
|
| 268 |
+
data-active={props.active ? "" : undefined}
|
| 269 |
+
onMouseMove={(event) => {
|
| 270 |
+
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
|
| 271 |
+
if (event.movementX === 0 && event.movementY === 0) return
|
| 272 |
+
props.onActive()
|
| 273 |
+
}}
|
| 274 |
+
onMouseDown={(event) => event.preventDefault()}
|
| 275 |
+
onClick={props.onSelect}
|
| 276 |
+
>
|
| 277 |
+
<Switch
|
| 278 |
+
fallback={
|
| 279 |
+
<div class="command-palette-v2-row-main">
|
| 280 |
+
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
|
| 281 |
+
<div class="command-palette-v2-file-path">
|
| 282 |
+
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
|
| 283 |
+
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
}
|
| 287 |
+
>
|
| 288 |
+
<Match when={props.item.type === "command"}>
|
| 289 |
+
<div class="command-palette-v2-row-main">
|
| 290 |
+
<div class="command-palette-v2-row-text">
|
| 291 |
+
<span class="command-palette-v2-title">{props.item.title}</span>
|
| 292 |
+
<Show when={props.item.description}>
|
| 293 |
+
<span class="command-palette-v2-description">{props.item.description}</span>
|
| 294 |
+
</Show>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
<Show when={props.item.keybind}>
|
| 298 |
+
<KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
|
| 299 |
+
</Show>
|
| 300 |
+
</Match>
|
| 301 |
+
<Match when={props.item.type === "session"}>
|
| 302 |
+
<div class="command-palette-v2-row-main">
|
| 303 |
+
<div class="relative shrink-0">
|
| 304 |
+
<Show when={props.sessionOpen}>
|
| 305 |
+
<span
|
| 306 |
+
aria-hidden="true"
|
| 307 |
+
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
| 308 |
+
style={{ right: "calc(100% + 4px)" }}
|
| 309 |
+
/>
|
| 310 |
+
</Show>
|
| 311 |
+
<Show when={session()}>
|
| 312 |
+
{(session) => (
|
| 313 |
+
<SessionTabAvatar
|
| 314 |
+
project={props.item.project}
|
| 315 |
+
directory={session().directory}
|
| 316 |
+
sessionId={session().sessionID}
|
| 317 |
+
server={session().server}
|
| 318 |
+
/>
|
| 319 |
+
)}
|
| 320 |
+
</Show>
|
| 321 |
+
</div>
|
| 322 |
+
<div class="command-palette-v2-row-text">
|
| 323 |
+
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
| 324 |
+
{props.item.title}
|
| 325 |
+
</span>
|
| 326 |
+
<Show when={props.item.description}>
|
| 327 |
+
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
|
| 328 |
+
{props.item.description}
|
| 329 |
+
</span>
|
| 330 |
+
</Show>
|
| 331 |
+
</div>
|
| 332 |
+
</div>
|
| 333 |
+
<Show when={props.item.updated}>
|
| 334 |
+
<span class="command-palette-v2-meta">
|
| 335 |
+
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
|
| 336 |
+
</span>
|
| 337 |
+
</Show>
|
| 338 |
+
</Match>
|
| 339 |
+
</Switch>
|
| 340 |
+
</button>
|
| 341 |
+
)
|
| 342 |
+
}
|
packages/app/src/components/dialog-connect-provider.stories.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @ts-nocheck
|
| 2 |
+
import { Button } from "@opencode-ai/ui/button"
|
| 3 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 4 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
| 5 |
+
import { mockProviderAuth } from "@/context/server-sync"
|
| 6 |
+
import { onCleanup, onMount } from "solid-js"
|
| 7 |
+
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
| 8 |
+
|
| 9 |
+
function ConnectProviderDialogStory() {
|
| 10 |
+
const dialog = useDialog()
|
| 11 |
+
const open = () => dialog.show(() => <DialogConnectProvider />)
|
| 12 |
+
|
| 13 |
+
onMount(open)
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<Button variant="secondary" onClick={open}>
|
| 17 |
+
Open connect provider dialog
|
| 18 |
+
</Button>
|
| 19 |
+
)
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function ProviderConnectionDialogStory(props) {
|
| 23 |
+
onCleanup(mockProviderAuth(props.provider, props.methods))
|
| 24 |
+
const dialog = useDialog()
|
| 25 |
+
const controller = useProviderConnectController()
|
| 26 |
+
controller.select(props.provider)
|
| 27 |
+
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
|
| 28 |
+
|
| 29 |
+
onMount(open)
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<Button variant="secondary" onClick={open}>
|
| 33 |
+
Open {props.provider} connection dialog
|
| 34 |
+
</Button>
|
| 35 |
+
)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function renderConnection(provider, methods) {
|
| 39 |
+
return () => (
|
| 40 |
+
<QueryClientProvider client={new QueryClient()}>
|
| 41 |
+
<ProviderConnectionDialogStory provider={provider} methods={methods} />
|
| 42 |
+
</QueryClientProvider>
|
| 43 |
+
)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export default {
|
| 47 |
+
title: "App/Dialogs/Connect Provider",
|
| 48 |
+
id: "app-dialog-connect-provider",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export const V2 = {
|
| 52 |
+
render: () => (
|
| 53 |
+
<QueryClientProvider client={new QueryClient()}>
|
| 54 |
+
<ConnectProviderDialogStory />
|
| 55 |
+
</QueryClientProvider>
|
| 56 |
+
),
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export const ApiKey = {
|
| 60 |
+
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export const OpenCodeZen = {
|
| 64 |
+
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export const LoginMethods = {
|
| 68 |
+
render: renderConnection("openai", [
|
| 69 |
+
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
|
| 70 |
+
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
|
| 71 |
+
{ type: "api", label: "API key" },
|
| 72 |
+
]),
|
| 73 |
+
}
|
packages/app/src/components/dialog-connect-provider.tsx
ADDED
|
@@ -0,0 +1,1191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
| 2 |
+
import { Button } from "@opencode-ai/ui/button"
|
| 3 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 4 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 5 |
+
import { Icon } from "@opencode-ai/ui/icon"
|
| 6 |
+
import { IconButton } from "@opencode-ai/ui/icon-button"
|
| 7 |
+
import { List, type ListRef } from "@opencode-ai/ui/list"
|
| 8 |
+
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
| 9 |
+
import { Spinner } from "@opencode-ai/ui/spinner"
|
| 10 |
+
import { Tag } from "@opencode-ai/ui/tag"
|
| 11 |
+
import { TextField } from "@opencode-ai/ui/text-field"
|
| 12 |
+
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
| 13 |
+
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
| 14 |
+
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
| 15 |
+
import { showToast } from "@/utils/toast"
|
| 16 |
+
import {
|
| 17 |
+
type Accessor,
|
| 18 |
+
type Component,
|
| 19 |
+
createEffect,
|
| 20 |
+
createMemo,
|
| 21 |
+
createResource,
|
| 22 |
+
createUniqueId,
|
| 23 |
+
For,
|
| 24 |
+
Match,
|
| 25 |
+
onCleanup,
|
| 26 |
+
onMount,
|
| 27 |
+
Show,
|
| 28 |
+
Switch,
|
| 29 |
+
} from "solid-js"
|
| 30 |
+
import { createStore, produce } from "solid-js/store"
|
| 31 |
+
import { useParams } from "@solidjs/router"
|
| 32 |
+
import { ExternalLink } from "@/components/external-link"
|
| 33 |
+
import { useServerSDK } from "@/context/server-sdk"
|
| 34 |
+
import { useServerSync } from "@/context/server-sync"
|
| 35 |
+
import { useLanguage } from "@/context/language"
|
| 36 |
+
import { usePlatform } from "@/context/platform"
|
| 37 |
+
import { useSettings } from "@/context/settings"
|
| 38 |
+
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
| 39 |
+
import { CustomProviderForm } from "./dialog-custom-provider"
|
| 40 |
+
import { decode64 } from "@/utils/base64"
|
| 41 |
+
|
| 42 |
+
const CUSTOM_ID = "_custom"
|
| 43 |
+
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
| 44 |
+
|
| 45 |
+
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
| 46 |
+
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
| 47 |
+
const reset = () => setStore("selected", undefined)
|
| 48 |
+
|
| 49 |
+
return {
|
| 50 |
+
selected: () => store.selected,
|
| 51 |
+
select: (provider?: string) => setStore("selected", provider),
|
| 52 |
+
back: options.onBack ?? reset,
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export const DialogConnectProvider: Component<{
|
| 57 |
+
directory?: Accessor<string | undefined>
|
| 58 |
+
controller?: ReturnType<typeof useProviderConnectController>
|
| 59 |
+
}> = (props) => {
|
| 60 |
+
const fallback = useProviderConnectController()
|
| 61 |
+
const controller = props.controller ?? fallback
|
| 62 |
+
const language = useLanguage()
|
| 63 |
+
const settings = useSettings()
|
| 64 |
+
const newLayout = settings.general.newLayoutDesigns
|
| 65 |
+
const reset = controller.back
|
| 66 |
+
const back = { current: reset }
|
| 67 |
+
let focusHost: HTMLDivElement | undefined
|
| 68 |
+
const holdFocus = () => focusHost?.focus({ preventScroll: true })
|
| 69 |
+
const select = (provider?: string) => {
|
| 70 |
+
back.current = reset
|
| 71 |
+
controller.select(provider)
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function Content() {
|
| 75 |
+
return (
|
| 76 |
+
<Switch>
|
| 77 |
+
<Match when={controller.selected() === CUSTOM_ID}>
|
| 78 |
+
<CustomProviderForm autofocus={!newLayout()} />
|
| 79 |
+
</Match>
|
| 80 |
+
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
| 81 |
+
{(provider) => (
|
| 82 |
+
<ProviderConnection
|
| 83 |
+
provider={provider()}
|
| 84 |
+
directory={props.directory}
|
| 85 |
+
onBack={reset}
|
| 86 |
+
setBack={(handler) => (back.current = handler)}
|
| 87 |
+
/>
|
| 88 |
+
)}
|
| 89 |
+
</Match>
|
| 90 |
+
<Match when={true}>
|
| 91 |
+
<ProviderPicker
|
| 92 |
+
directory={props.directory}
|
| 93 |
+
onSelect={select}
|
| 94 |
+
onPrepare={newLayout() ? holdFocus : undefined}
|
| 95 |
+
/>
|
| 96 |
+
</Match>
|
| 97 |
+
</Switch>
|
| 98 |
+
)
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
return (
|
| 102 |
+
<Show
|
| 103 |
+
when={newLayout()}
|
| 104 |
+
fallback={
|
| 105 |
+
<Dialog
|
| 106 |
+
class="h-full"
|
| 107 |
+
transition
|
| 108 |
+
title={
|
| 109 |
+
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
| 110 |
+
<IconButton
|
| 111 |
+
tabIndex={-1}
|
| 112 |
+
icon="arrow-left"
|
| 113 |
+
variant="ghost"
|
| 114 |
+
onClick={() => back.current()}
|
| 115 |
+
aria-label={language.t("common.goBack")}
|
| 116 |
+
/>
|
| 117 |
+
</Show>
|
| 118 |
+
}
|
| 119 |
+
>
|
| 120 |
+
<Content />
|
| 121 |
+
</Dialog>
|
| 122 |
+
}
|
| 123 |
+
>
|
| 124 |
+
<DialogV2
|
| 125 |
+
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
| 126 |
+
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
| 127 |
+
>
|
| 128 |
+
<DialogHeader closeLabel={language.t("common.close")}>
|
| 129 |
+
<Show
|
| 130 |
+
when={controller.selected()}
|
| 131 |
+
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
|
| 132 |
+
>
|
| 133 |
+
<button
|
| 134 |
+
type="button"
|
| 135 |
+
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
| 136 |
+
onClick={() => back.current()}
|
| 137 |
+
aria-label={language.t("common.goBack")}
|
| 138 |
+
>
|
| 139 |
+
<Icon name="arrow-left" size="small" />
|
| 140 |
+
</button>
|
| 141 |
+
</Show>
|
| 142 |
+
</DialogHeader>
|
| 143 |
+
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
|
| 144 |
+
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
|
| 145 |
+
<Content />
|
| 146 |
+
</div>
|
| 147 |
+
</DialogBody>
|
| 148 |
+
</DialogV2>
|
| 149 |
+
</Show>
|
| 150 |
+
)
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
function ProviderPicker(props: {
|
| 154 |
+
directory?: Accessor<string | undefined>
|
| 155 |
+
onSelect: (provider: string) => void
|
| 156 |
+
onPrepare?: () => void
|
| 157 |
+
}) {
|
| 158 |
+
const settings = useSettings()
|
| 159 |
+
if (settings.general.newLayoutDesigns())
|
| 160 |
+
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
| 161 |
+
const providers = useProviders(() => props.directory?.())
|
| 162 |
+
const language = useLanguage()
|
| 163 |
+
const popularGroup = () => language.t("dialog.provider.group.popular")
|
| 164 |
+
const otherGroup = () => language.t("dialog.provider.group.other")
|
| 165 |
+
const customLabel = () => language.t("settings.providers.tag.custom")
|
| 166 |
+
const note = (id: string) => {
|
| 167 |
+
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
| 168 |
+
if (id === "openai") return language.t("dialog.provider.openai.note")
|
| 169 |
+
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
| 170 |
+
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
|
| 171 |
+
return undefined
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
return (
|
| 175 |
+
<List
|
| 176 |
+
class="px-3"
|
| 177 |
+
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
|
| 178 |
+
emptyMessage={language.t("dialog.provider.empty")}
|
| 179 |
+
activeIcon="plus-small"
|
| 180 |
+
key={(x) => x?.id}
|
| 181 |
+
items={() => {
|
| 182 |
+
language.locale()
|
| 183 |
+
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
| 184 |
+
}}
|
| 185 |
+
filterKeys={["id", "name"]}
|
| 186 |
+
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
| 187 |
+
sortBy={(a, b) => {
|
| 188 |
+
if (a.id === CUSTOM_ID) return -1
|
| 189 |
+
if (b.id === CUSTOM_ID) return 1
|
| 190 |
+
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
| 191 |
+
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
| 192 |
+
return a.name.localeCompare(b.name)
|
| 193 |
+
}}
|
| 194 |
+
sortGroupsBy={(a, b) => {
|
| 195 |
+
const popular = popularGroup()
|
| 196 |
+
if (a.category === popular && b.category !== popular) return -1
|
| 197 |
+
if (b.category === popular && a.category !== popular) return 1
|
| 198 |
+
return 0
|
| 199 |
+
}}
|
| 200 |
+
onSelect={(x) => {
|
| 201 |
+
if (!x) return
|
| 202 |
+
props.onSelect(x.id)
|
| 203 |
+
}}
|
| 204 |
+
>
|
| 205 |
+
{(i) => (
|
| 206 |
+
<div class="px-1.25 w-full flex items-center gap-x-3">
|
| 207 |
+
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
| 208 |
+
<span>{i.name}</span>
|
| 209 |
+
<Show when={i.id === "opencode"}>
|
| 210 |
+
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
| 211 |
+
</Show>
|
| 212 |
+
<Show when={i.id === CUSTOM_ID}>
|
| 213 |
+
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
| 214 |
+
</Show>
|
| 215 |
+
<Show when={i.id === "opencode"}>
|
| 216 |
+
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
| 217 |
+
</Show>
|
| 218 |
+
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
| 219 |
+
<Show when={i.id === "opencode-go"}>
|
| 220 |
+
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
| 221 |
+
</Show>
|
| 222 |
+
</div>
|
| 223 |
+
)}
|
| 224 |
+
</List>
|
| 225 |
+
)
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function ProviderPickerV2(props: {
|
| 229 |
+
directory?: Accessor<string | undefined>
|
| 230 |
+
onSelect: (provider: string) => void
|
| 231 |
+
onPrepare?: () => void
|
| 232 |
+
}) {
|
| 233 |
+
const providers = useProviders(() => props.directory?.())
|
| 234 |
+
const language = useLanguage()
|
| 235 |
+
const [store, setStore] = createStore({
|
| 236 |
+
filter: "",
|
| 237 |
+
active: undefined as string | undefined,
|
| 238 |
+
connecting: undefined as string | undefined,
|
| 239 |
+
})
|
| 240 |
+
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
|
| 241 |
+
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
|
| 242 |
+
const all = createMemo(() => {
|
| 243 |
+
language.locale()
|
| 244 |
+
const query = store.filter.trim().toLowerCase()
|
| 245 |
+
const values = [custom(), ...providers.all().values()]
|
| 246 |
+
if (!query) return values
|
| 247 |
+
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
|
| 248 |
+
})
|
| 249 |
+
const popular = createMemo(() =>
|
| 250 |
+
all()
|
| 251 |
+
.filter((provider) => featured.includes(provider.id))
|
| 252 |
+
.sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
|
| 253 |
+
)
|
| 254 |
+
const other = createMemo(() =>
|
| 255 |
+
all()
|
| 256 |
+
.filter((provider) => !featured.includes(provider.id))
|
| 257 |
+
.sort((a, b) => {
|
| 258 |
+
if (a.id === CUSTOM_ID) return -1
|
| 259 |
+
if (b.id === CUSTOM_ID) return 1
|
| 260 |
+
return a.name.localeCompare(b.name)
|
| 261 |
+
}),
|
| 262 |
+
)
|
| 263 |
+
const rows = createMemo(() => [...popular(), ...other()])
|
| 264 |
+
let picker: HTMLDivElement | undefined
|
| 265 |
+
let search: HTMLInputElement | undefined
|
| 266 |
+
|
| 267 |
+
onMount(() => search?.focus({ preventScroll: true }))
|
| 268 |
+
|
| 269 |
+
const connect = (provider: string) => {
|
| 270 |
+
props.onPrepare?.()
|
| 271 |
+
props.onSelect(provider)
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
const move = (event: KeyboardEvent, direction: number) => {
|
| 275 |
+
const items = rows()
|
| 276 |
+
if (items.length === 0) return
|
| 277 |
+
const index = items.findIndex((provider) => provider.id === store.active)
|
| 278 |
+
const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
|
| 279 |
+
setStore("active", items[next].id)
|
| 280 |
+
picker
|
| 281 |
+
?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
|
| 282 |
+
?.focus({ preventScroll: true })
|
| 283 |
+
event.preventDefault()
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
const handleKeyDown = (event: KeyboardEvent) => {
|
| 287 |
+
if (event.key === "ArrowDown") return move(event, 1)
|
| 288 |
+
if (event.key === "ArrowUp") return move(event, -1)
|
| 289 |
+
if (event.key !== "Enter" || !store.active) return
|
| 290 |
+
connect(store.active)
|
| 291 |
+
event.preventDefault()
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
return (
|
| 295 |
+
<div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
|
| 296 |
+
<div class="shrink-0 px-1 pt-px">
|
| 297 |
+
<TextInputV2
|
| 298 |
+
ref={search}
|
| 299 |
+
type="search"
|
| 300 |
+
class="!w-full [font-family:var(--v2-font-family-sans)]"
|
| 301 |
+
leadingIcon={<Icon name="magnifying-glass" size="small" />}
|
| 302 |
+
placeholder={language.t("dialog.provider.search.placeholder")}
|
| 303 |
+
value={store.filter}
|
| 304 |
+
onInput={(event) => {
|
| 305 |
+
setStore({ filter: event.currentTarget.value, active: undefined })
|
| 306 |
+
}}
|
| 307 |
+
/>
|
| 308 |
+
</div>
|
| 309 |
+
<div class="relative min-h-0 flex-1">
|
| 310 |
+
<div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
| 311 |
+
<For
|
| 312 |
+
each={[
|
| 313 |
+
{ title: language.t("dialog.provider.group.popular"), items: popular },
|
| 314 |
+
{ title: language.t("dialog.provider.group.other"), items: other },
|
| 315 |
+
]}
|
| 316 |
+
>
|
| 317 |
+
{(group) => (
|
| 318 |
+
<Show when={group.items().length > 0}>
|
| 319 |
+
<section class="flex flex-col">
|
| 320 |
+
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
|
| 321 |
+
{group.title}
|
| 322 |
+
</div>
|
| 323 |
+
<For each={group.items()}>
|
| 324 |
+
{(provider) => (
|
| 325 |
+
<button
|
| 326 |
+
type="button"
|
| 327 |
+
data-provider-id={provider.id}
|
| 328 |
+
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
| 329 |
+
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
|
| 330 |
+
onMouseEnter={() => setStore("active", provider.id)}
|
| 331 |
+
disabled={store.connecting !== undefined}
|
| 332 |
+
aria-busy={store.connecting === provider.id}
|
| 333 |
+
onClick={() => connect(provider.id)}
|
| 334 |
+
>
|
| 335 |
+
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
|
| 336 |
+
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
|
| 337 |
+
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
| 338 |
+
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
|
| 339 |
+
{language.t(
|
| 340 |
+
provider.id === "opencode"
|
| 341 |
+
? "dialog.provider.opencode.tagline"
|
| 342 |
+
: "dialog.provider.opencodeGo.tagline",
|
| 343 |
+
)}
|
| 344 |
+
</span>
|
| 345 |
+
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
| 346 |
+
{language.t("dialog.provider.tag.recommended")}
|
| 347 |
+
</span>
|
| 348 |
+
</Show>
|
| 349 |
+
<Show when={provider.id === CUSTOM_ID}>
|
| 350 |
+
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
| 351 |
+
{language.t("settings.providers.tag.custom")}
|
| 352 |
+
</span>
|
| 353 |
+
</Show>
|
| 354 |
+
<Show when={store.connecting === provider.id}>
|
| 355 |
+
<Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
|
| 356 |
+
</Show>
|
| 357 |
+
</button>
|
| 358 |
+
)}
|
| 359 |
+
</For>
|
| 360 |
+
</section>
|
| 361 |
+
</Show>
|
| 362 |
+
)}
|
| 363 |
+
</For>
|
| 364 |
+
<Show when={rows().length === 0}>
|
| 365 |
+
<div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
|
| 366 |
+
{language.t("dialog.provider.empty")}
|
| 367 |
+
</div>
|
| 368 |
+
</Show>
|
| 369 |
+
</div>
|
| 370 |
+
<div
|
| 371 |
+
class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
|
| 372 |
+
style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
|
| 373 |
+
/>
|
| 374 |
+
</div>
|
| 375 |
+
</div>
|
| 376 |
+
)
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
function ProviderConnection(props: {
|
| 380 |
+
provider: string
|
| 381 |
+
directory?: Accessor<string | undefined>
|
| 382 |
+
onBack: () => void
|
| 383 |
+
setBack: (handler: () => void) => void
|
| 384 |
+
}) {
|
| 385 |
+
const dialog = useDialog()
|
| 386 |
+
const serverSync = useServerSync()
|
| 387 |
+
const serverSDK = useServerSDK()
|
| 388 |
+
const params = useParams()
|
| 389 |
+
const language = useLanguage()
|
| 390 |
+
const platform = usePlatform()
|
| 391 |
+
const settings = useSettings()
|
| 392 |
+
const newLayout = settings.general.newLayoutDesigns
|
| 393 |
+
const providers = useProviders(() => props.directory?.())
|
| 394 |
+
const directory = () => props.directory?.() ?? decode64(params.dir)
|
| 395 |
+
const location = () => {
|
| 396 |
+
const value = directory()
|
| 397 |
+
return value ? { directory: value } : undefined
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
const alive = { value: true }
|
| 401 |
+
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
| 402 |
+
|
| 403 |
+
onCleanup(() => {
|
| 404 |
+
alive.value = false
|
| 405 |
+
if (timer.current === undefined) return
|
| 406 |
+
clearTimeout(timer.current)
|
| 407 |
+
timer.current = undefined
|
| 408 |
+
})
|
| 409 |
+
|
| 410 |
+
const provider = createMemo(
|
| 411 |
+
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
| 412 |
+
)
|
| 413 |
+
const fallback = createMemo<ConnectMethod[]>(() => [
|
| 414 |
+
{
|
| 415 |
+
type: "key" as const,
|
| 416 |
+
label: language.t("provider.connect.method.apiKey"),
|
| 417 |
+
},
|
| 418 |
+
])
|
| 419 |
+
const [integration] = createResource(
|
| 420 |
+
() => ({ provider: props.provider, directory: directory() }),
|
| 421 |
+
(input) =>
|
| 422 |
+
serverSDK()
|
| 423 |
+
.api.integration.get({
|
| 424 |
+
integrationID: input.provider,
|
| 425 |
+
location: input.directory ? { directory: input.directory } : undefined,
|
| 426 |
+
})
|
| 427 |
+
.then((result) => result.data),
|
| 428 |
+
)
|
| 429 |
+
const loading = createMemo(() => integration.loading)
|
| 430 |
+
const methods = createMemo<ConnectMethod[]>(() => {
|
| 431 |
+
const values = integration.latest?.methods.filter(
|
| 432 |
+
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
| 433 |
+
)
|
| 434 |
+
return values?.length ? values : fallback()
|
| 435 |
+
})
|
| 436 |
+
const [store, setStore] = createStore({
|
| 437 |
+
methodIndex: undefined as undefined | number,
|
| 438 |
+
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
| 439 |
+
promptInputs: undefined as undefined | Record<string, string>,
|
| 440 |
+
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
| 441 |
+
error: undefined as string | undefined,
|
| 442 |
+
})
|
| 443 |
+
|
| 444 |
+
type Action =
|
| 445 |
+
| { type: "method.select"; index: number }
|
| 446 |
+
| { type: "method.reset" }
|
| 447 |
+
| { type: "auth.prompt" }
|
| 448 |
+
| { type: "auth.inputs"; inputs: Record<string, string> }
|
| 449 |
+
| { type: "auth.pending" }
|
| 450 |
+
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
| 451 |
+
| { type: "auth.error"; error: string }
|
| 452 |
+
|
| 453 |
+
function dispatch(action: Action) {
|
| 454 |
+
setStore(
|
| 455 |
+
produce((draft) => {
|
| 456 |
+
if (action.type === "method.select") {
|
| 457 |
+
draft.methodIndex = action.index
|
| 458 |
+
draft.authorization = undefined
|
| 459 |
+
draft.promptInputs = undefined
|
| 460 |
+
draft.state = undefined
|
| 461 |
+
draft.error = undefined
|
| 462 |
+
return
|
| 463 |
+
}
|
| 464 |
+
if (action.type === "method.reset") {
|
| 465 |
+
draft.methodIndex = undefined
|
| 466 |
+
draft.authorization = undefined
|
| 467 |
+
draft.promptInputs = undefined
|
| 468 |
+
draft.state = undefined
|
| 469 |
+
draft.error = undefined
|
| 470 |
+
return
|
| 471 |
+
}
|
| 472 |
+
if (action.type === "auth.prompt") {
|
| 473 |
+
draft.state = "prompt"
|
| 474 |
+
draft.error = undefined
|
| 475 |
+
return
|
| 476 |
+
}
|
| 477 |
+
if (action.type === "auth.inputs") {
|
| 478 |
+
draft.promptInputs = action.inputs
|
| 479 |
+
draft.state = undefined
|
| 480 |
+
draft.error = undefined
|
| 481 |
+
return
|
| 482 |
+
}
|
| 483 |
+
if (action.type === "auth.pending") {
|
| 484 |
+
draft.state = "pending"
|
| 485 |
+
draft.error = undefined
|
| 486 |
+
return
|
| 487 |
+
}
|
| 488 |
+
if (action.type === "auth.complete") {
|
| 489 |
+
draft.state = "complete"
|
| 490 |
+
draft.authorization = action.authorization
|
| 491 |
+
draft.error = undefined
|
| 492 |
+
return
|
| 493 |
+
}
|
| 494 |
+
draft.state = "error"
|
| 495 |
+
draft.error = action.error
|
| 496 |
+
}),
|
| 497 |
+
)
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
| 501 |
+
|
| 502 |
+
const methodLabel = (value?: { type?: string; label?: string }) => {
|
| 503 |
+
if (!value) return ""
|
| 504 |
+
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
| 505 |
+
return value.label ?? ""
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
const methodDetails = (value?: { type?: string; label?: string }) => {
|
| 509 |
+
const label = methodLabel(value)
|
| 510 |
+
const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
|
| 511 |
+
const hint = suffix?.[1]
|
| 512 |
+
return {
|
| 513 |
+
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
| 514 |
+
hint:
|
| 515 |
+
hint?.toLowerCase() === "headless"
|
| 516 |
+
? language.t("provider.connect.method.headless")
|
| 517 |
+
: hint?.toLowerCase() === "browser" || (!hint && value?.type === "key")
|
| 518 |
+
? language.t("provider.connect.method.browser")
|
| 519 |
+
: undefined,
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
function formatError(value: unknown, fallback: string): string {
|
| 524 |
+
if (value && typeof value === "object" && "data" in value) {
|
| 525 |
+
const data = (value as { data?: { message?: unknown } }).data
|
| 526 |
+
if (typeof data?.message === "string" && data.message) return data.message
|
| 527 |
+
}
|
| 528 |
+
if (value && typeof value === "object" && "error" in value) {
|
| 529 |
+
const nested = formatError((value as { error?: unknown }).error, "")
|
| 530 |
+
if (nested) return nested
|
| 531 |
+
}
|
| 532 |
+
if (value && typeof value === "object" && "message" in value) {
|
| 533 |
+
const message = (value as { message?: unknown }).message
|
| 534 |
+
if (typeof message === "string" && message) return message
|
| 535 |
+
}
|
| 536 |
+
if (value instanceof Error && value.message) return value.message
|
| 537 |
+
if (typeof value === "string" && value) return value
|
| 538 |
+
return fallback
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
async function selectMethod(index: number, inputs?: Record<string, string>) {
|
| 542 |
+
if (timer.current !== undefined) {
|
| 543 |
+
clearTimeout(timer.current)
|
| 544 |
+
timer.current = undefined
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
const method = methods()[index]
|
| 548 |
+
dispatch({ type: "method.select", index })
|
| 549 |
+
|
| 550 |
+
if (method.type === "oauth") {
|
| 551 |
+
if (method.prompts?.length && !inputs) {
|
| 552 |
+
dispatch({ type: "auth.prompt" })
|
| 553 |
+
return
|
| 554 |
+
}
|
| 555 |
+
dispatch({ type: "auth.pending" })
|
| 556 |
+
await serverSDK()
|
| 557 |
+
.api.integration.oauth.connect({
|
| 558 |
+
integrationID: props.provider,
|
| 559 |
+
methodID: method.id,
|
| 560 |
+
inputs: inputs ?? {},
|
| 561 |
+
location: location(),
|
| 562 |
+
})
|
| 563 |
+
.then((x) => {
|
| 564 |
+
if (!alive.value) return
|
| 565 |
+
if (props.provider === "opencode" && platform.platform === "desktop") {
|
| 566 |
+
const url = new URL(x.data.url)
|
| 567 |
+
url.searchParams.set("client_id", "opencode-desktop")
|
| 568 |
+
x.data.url = url.href
|
| 569 |
+
}
|
| 570 |
+
dispatch({ type: "auth.complete", authorization: x.data })
|
| 571 |
+
})
|
| 572 |
+
.catch((e) => {
|
| 573 |
+
if (!alive.value) return
|
| 574 |
+
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
|
| 575 |
+
})
|
| 576 |
+
}
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
function AuthPromptsView() {
|
| 580 |
+
const [formStore, setFormStore] = createStore({
|
| 581 |
+
value: {} as Record<string, string>,
|
| 582 |
+
index: 0,
|
| 583 |
+
})
|
| 584 |
+
|
| 585 |
+
const prompts = createMemo(() => {
|
| 586 |
+
const value = method()
|
| 587 |
+
return value?.type === "oauth" ? (value.prompts ?? []) : []
|
| 588 |
+
})
|
| 589 |
+
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
| 590 |
+
if (!prompt.when) return true
|
| 591 |
+
const actual = value[prompt.when.key]
|
| 592 |
+
if (actual === undefined) return false
|
| 593 |
+
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
|
| 594 |
+
}
|
| 595 |
+
const current = createMemo(() => {
|
| 596 |
+
const all = prompts()
|
| 597 |
+
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
|
| 598 |
+
if (index === -1) return
|
| 599 |
+
return {
|
| 600 |
+
index,
|
| 601 |
+
prompt: all[index],
|
| 602 |
+
}
|
| 603 |
+
})
|
| 604 |
+
const valid = createMemo(() => {
|
| 605 |
+
const item = current()
|
| 606 |
+
if (!item || item.prompt.type !== "text") return false
|
| 607 |
+
const value = formStore.value[item.prompt.key] ?? ""
|
| 608 |
+
return value.trim().length > 0
|
| 609 |
+
})
|
| 610 |
+
|
| 611 |
+
async function next(index: number, value: Record<string, string>) {
|
| 612 |
+
if (store.methodIndex === undefined) return
|
| 613 |
+
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
|
| 614 |
+
if (next !== -1) {
|
| 615 |
+
setFormStore("index", next)
|
| 616 |
+
return
|
| 617 |
+
}
|
| 618 |
+
await selectMethod(store.methodIndex, value)
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
async function handleSubmit(e: SubmitEvent) {
|
| 622 |
+
e.preventDefault()
|
| 623 |
+
const item = current()
|
| 624 |
+
if (!item || item.prompt.type !== "text") return
|
| 625 |
+
if (!valid()) return
|
| 626 |
+
await next(item.index, formStore.value)
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
const item = () => current()
|
| 630 |
+
const text = createMemo(() => {
|
| 631 |
+
const prompt = item()?.prompt
|
| 632 |
+
if (!prompt || prompt.type !== "text") return
|
| 633 |
+
return prompt
|
| 634 |
+
})
|
| 635 |
+
const select = createMemo(() => {
|
| 636 |
+
const prompt = item()?.prompt
|
| 637 |
+
if (!prompt || prompt.type !== "select") return
|
| 638 |
+
return prompt
|
| 639 |
+
})
|
| 640 |
+
|
| 641 |
+
return (
|
| 642 |
+
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
| 643 |
+
<Switch>
|
| 644 |
+
<Match when={item()?.prompt.type === "text"}>
|
| 645 |
+
<TextField
|
| 646 |
+
type="text"
|
| 647 |
+
label={text()?.message ?? ""}
|
| 648 |
+
placeholder={text()?.placeholder}
|
| 649 |
+
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
|
| 650 |
+
onChange={(value) => {
|
| 651 |
+
const prompt = text()
|
| 652 |
+
if (!prompt) return
|
| 653 |
+
setFormStore("value", prompt.key, value)
|
| 654 |
+
}}
|
| 655 |
+
/>
|
| 656 |
+
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
|
| 657 |
+
{language.t("common.continue")}
|
| 658 |
+
</Button>
|
| 659 |
+
</Match>
|
| 660 |
+
<Match when={item()?.prompt.type === "select"}>
|
| 661 |
+
<div class="w-full flex flex-col gap-1.5">
|
| 662 |
+
<div class="text-14-regular text-text-base">{select()?.message}</div>
|
| 663 |
+
<div>
|
| 664 |
+
<List
|
| 665 |
+
class="px-3"
|
| 666 |
+
items={select()?.options ?? []}
|
| 667 |
+
key={(x) => x.value}
|
| 668 |
+
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
| 669 |
+
onSelect={(value) => {
|
| 670 |
+
if (!value) return
|
| 671 |
+
const prompt = select()
|
| 672 |
+
if (!prompt) return
|
| 673 |
+
const nextValue = {
|
| 674 |
+
...formStore.value,
|
| 675 |
+
[prompt.key]: value.value,
|
| 676 |
+
}
|
| 677 |
+
setFormStore("value", prompt.key, value.value)
|
| 678 |
+
void next(item()!.index, nextValue)
|
| 679 |
+
}}
|
| 680 |
+
>
|
| 681 |
+
{(option) => (
|
| 682 |
+
<div class="w-full flex items-center gap-x-2">
|
| 683 |
+
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
| 684 |
+
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
| 685 |
+
</div>
|
| 686 |
+
<span>{option.label}</span>
|
| 687 |
+
<span class="text-14-regular text-text-weak">{option.hint}</span>
|
| 688 |
+
</div>
|
| 689 |
+
)}
|
| 690 |
+
</List>
|
| 691 |
+
</div>
|
| 692 |
+
</div>
|
| 693 |
+
</Match>
|
| 694 |
+
</Switch>
|
| 695 |
+
</form>
|
| 696 |
+
)
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
let listRef: ListRef | undefined
|
| 700 |
+
function handleKey(e: KeyboardEvent) {
|
| 701 |
+
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
| 702 |
+
return
|
| 703 |
+
}
|
| 704 |
+
if (e.key === "Escape") return
|
| 705 |
+
listRef?.onKeyDown(e)
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
let auto = false
|
| 709 |
+
createEffect(() => {
|
| 710 |
+
if (auto) return
|
| 711 |
+
if (loading()) return
|
| 712 |
+
if (methods().length === 1) {
|
| 713 |
+
auto = true
|
| 714 |
+
void selectMethod(0)
|
| 715 |
+
}
|
| 716 |
+
})
|
| 717 |
+
|
| 718 |
+
async function complete() {
|
| 719 |
+
await serverSync()
|
| 720 |
+
.refreshProviders()
|
| 721 |
+
.catch(() => undefined)
|
| 722 |
+
dialog.close()
|
| 723 |
+
showToast({
|
| 724 |
+
variant: "success",
|
| 725 |
+
icon: "circle-check",
|
| 726 |
+
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
|
| 727 |
+
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
|
| 728 |
+
})
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
function goBack() {
|
| 732 |
+
if (methods().length > 1 && store.methodIndex !== undefined) {
|
| 733 |
+
dispatch({ type: "method.reset" })
|
| 734 |
+
return
|
| 735 |
+
}
|
| 736 |
+
props.onBack()
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
props.setBack(goBack)
|
| 740 |
+
|
| 741 |
+
function MethodSelection() {
|
| 742 |
+
if (newLayout())
|
| 743 |
+
return (
|
| 744 |
+
<div class="flex flex-col gap-2">
|
| 745 |
+
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
| 746 |
+
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
| 747 |
+
</div>
|
| 748 |
+
<div class="flex flex-col">
|
| 749 |
+
<For each={methods()}>
|
| 750 |
+
{(item, index) => {
|
| 751 |
+
const details = () => methodDetails(item)
|
| 752 |
+
return (
|
| 753 |
+
<button
|
| 754 |
+
type="button"
|
| 755 |
+
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
| 756 |
+
onClick={() => void selectMethod(index())}
|
| 757 |
+
>
|
| 758 |
+
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
| 759 |
+
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
| 760 |
+
</span>
|
| 761 |
+
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
|
| 762 |
+
<Show when={details().hint}>
|
| 763 |
+
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
|
| 764 |
+
</Show>
|
| 765 |
+
</button>
|
| 766 |
+
)
|
| 767 |
+
}}
|
| 768 |
+
</For>
|
| 769 |
+
</div>
|
| 770 |
+
</div>
|
| 771 |
+
)
|
| 772 |
+
|
| 773 |
+
return (
|
| 774 |
+
<>
|
| 775 |
+
<div class="text-14-regular text-text-base">
|
| 776 |
+
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
| 777 |
+
</div>
|
| 778 |
+
<div>
|
| 779 |
+
<List
|
| 780 |
+
class="px-3"
|
| 781 |
+
ref={(ref) => {
|
| 782 |
+
listRef = ref
|
| 783 |
+
}}
|
| 784 |
+
items={methods}
|
| 785 |
+
key={(m) => m?.label ?? m?.type}
|
| 786 |
+
onSelect={async (selected, index) => {
|
| 787 |
+
if (!selected) return
|
| 788 |
+
void selectMethod(index)
|
| 789 |
+
}}
|
| 790 |
+
>
|
| 791 |
+
{(i) => (
|
| 792 |
+
<div class="w-full flex items-center gap-x-2">
|
| 793 |
+
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
| 794 |
+
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
| 795 |
+
</div>
|
| 796 |
+
<span>{methodLabel(i)}</span>
|
| 797 |
+
</div>
|
| 798 |
+
)}
|
| 799 |
+
</List>
|
| 800 |
+
</div>
|
| 801 |
+
</>
|
| 802 |
+
)
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
function ApiAuthView() {
|
| 806 |
+
let apiKey: HTMLInputElement | undefined
|
| 807 |
+
const errorID = createUniqueId()
|
| 808 |
+
const [formStore, setFormStore] = createStore({
|
| 809 |
+
value: "",
|
| 810 |
+
error: undefined as string | undefined,
|
| 811 |
+
})
|
| 812 |
+
|
| 813 |
+
onMount(() => {
|
| 814 |
+
if (!newLayout()) return
|
| 815 |
+
apiKey?.focus({ preventScroll: true })
|
| 816 |
+
})
|
| 817 |
+
|
| 818 |
+
async function handleSubmit(e: SubmitEvent) {
|
| 819 |
+
e.preventDefault()
|
| 820 |
+
|
| 821 |
+
const form = e.currentTarget as HTMLFormElement
|
| 822 |
+
const formData = new FormData(form)
|
| 823 |
+
const apiKey = formData.get("apiKey") as string
|
| 824 |
+
|
| 825 |
+
if (!apiKey?.trim()) {
|
| 826 |
+
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
| 827 |
+
return
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
setFormStore("error", undefined)
|
| 831 |
+
await serverSDK().api.integration.connect.key({
|
| 832 |
+
integrationID: props.provider,
|
| 833 |
+
location: location(),
|
| 834 |
+
key: apiKey,
|
| 835 |
+
})
|
| 836 |
+
await complete()
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
if (newLayout())
|
| 840 |
+
return (
|
| 841 |
+
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
| 842 |
+
<Show
|
| 843 |
+
when={provider().id === "opencode"}
|
| 844 |
+
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
| 845 |
+
>
|
| 846 |
+
<div class="flex flex-col gap-5">
|
| 847 |
+
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
|
| 848 |
+
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
| 849 |
+
<div>
|
| 850 |
+
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
| 851 |
+
<ExternalLink
|
| 852 |
+
href="https://opencode.ai/zen"
|
| 853 |
+
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
| 854 |
+
>
|
| 855 |
+
{language.t("provider.connect.opencodeZen.visit.link")}
|
| 856 |
+
</ExternalLink>
|
| 857 |
+
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
| 858 |
+
</div>
|
| 859 |
+
</div>
|
| 860 |
+
</Show>
|
| 861 |
+
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
| 862 |
+
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
| 863 |
+
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
| 864 |
+
<TextInputV2
|
| 865 |
+
ref={apiKey}
|
| 866 |
+
class="!w-full"
|
| 867 |
+
name="apiKey"
|
| 868 |
+
data-input="provider-api-key"
|
| 869 |
+
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
| 870 |
+
value={formStore.value}
|
| 871 |
+
invalid={formStore.error !== undefined}
|
| 872 |
+
aria-describedby={formStore.error ? errorID : undefined}
|
| 873 |
+
autocomplete="off"
|
| 874 |
+
spellcheck={false}
|
| 875 |
+
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
| 876 |
+
/>
|
| 877 |
+
</label>
|
| 878 |
+
<Show when={formStore.error}>
|
| 879 |
+
{(error) => (
|
| 880 |
+
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
| 881 |
+
{error()}
|
| 882 |
+
</div>
|
| 883 |
+
)}
|
| 884 |
+
</Show>
|
| 885 |
+
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
|
| 886 |
+
{language.t("common.continue")}
|
| 887 |
+
</ButtonV2>
|
| 888 |
+
</form>
|
| 889 |
+
</div>
|
| 890 |
+
)
|
| 891 |
+
|
| 892 |
+
return (
|
| 893 |
+
<div class="flex flex-col gap-6">
|
| 894 |
+
<Switch>
|
| 895 |
+
<Match when={provider().id === "opencode"}>
|
| 896 |
+
<div class="flex flex-col gap-4">
|
| 897 |
+
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
|
| 898 |
+
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
| 899 |
+
<div class="text-14-regular text-text-base">
|
| 900 |
+
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
| 901 |
+
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
|
| 902 |
+
{language.t("provider.connect.opencodeZen.visit.link")}
|
| 903 |
+
</ExternalLink>
|
| 904 |
+
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
| 905 |
+
</div>
|
| 906 |
+
</div>
|
| 907 |
+
</Match>
|
| 908 |
+
<Match when={true}>
|
| 909 |
+
<div class="text-14-regular text-text-base">
|
| 910 |
+
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
| 911 |
+
</div>
|
| 912 |
+
</Match>
|
| 913 |
+
</Switch>
|
| 914 |
+
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
| 915 |
+
<TextField
|
| 916 |
+
autofocus={!newLayout()}
|
| 917 |
+
ref={apiKey}
|
| 918 |
+
type="text"
|
| 919 |
+
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
| 920 |
+
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
| 921 |
+
name="apiKey"
|
| 922 |
+
value={formStore.value}
|
| 923 |
+
onChange={(v) => setFormStore("value", v)}
|
| 924 |
+
validationState={formStore.error ? "invalid" : undefined}
|
| 925 |
+
error={formStore.error}
|
| 926 |
+
/>
|
| 927 |
+
<Button class="w-auto" type="submit" size="large" variant="primary">
|
| 928 |
+
{language.t("common.continue")}
|
| 929 |
+
</Button>
|
| 930 |
+
</form>
|
| 931 |
+
</div>
|
| 932 |
+
)
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
function OAuthCodeView() {
|
| 936 |
+
let codeInput: HTMLInputElement | undefined
|
| 937 |
+
const errorID = createUniqueId()
|
| 938 |
+
const [formStore, setFormStore] = createStore({
|
| 939 |
+
value: "",
|
| 940 |
+
error: undefined as string | undefined,
|
| 941 |
+
})
|
| 942 |
+
|
| 943 |
+
onMount(() => {
|
| 944 |
+
if (!newLayout()) return
|
| 945 |
+
codeInput?.focus({ preventScroll: true })
|
| 946 |
+
})
|
| 947 |
+
|
| 948 |
+
async function handleSubmit(e: SubmitEvent) {
|
| 949 |
+
e.preventDefault()
|
| 950 |
+
|
| 951 |
+
const form = e.currentTarget as HTMLFormElement
|
| 952 |
+
const formData = new FormData(form)
|
| 953 |
+
const code = formData.get("code") as string
|
| 954 |
+
|
| 955 |
+
if (!code?.trim()) {
|
| 956 |
+
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
| 957 |
+
return
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
setFormStore("error", undefined)
|
| 961 |
+
const result = await serverSDK()
|
| 962 |
+
.api.integration.oauth.complete({
|
| 963 |
+
integrationID: props.provider,
|
| 964 |
+
attemptID: store.authorization!.attemptID,
|
| 965 |
+
location: location(),
|
| 966 |
+
code,
|
| 967 |
+
})
|
| 968 |
+
.then(() => ({ ok: true as const }))
|
| 969 |
+
.catch((error) => ({ ok: false as const, error }))
|
| 970 |
+
if (result.ok) {
|
| 971 |
+
await complete()
|
| 972 |
+
return
|
| 973 |
+
}
|
| 974 |
+
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
if (newLayout())
|
| 978 |
+
return (
|
| 979 |
+
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
| 980 |
+
<div>
|
| 981 |
+
{language.t("provider.connect.oauth.code.visit.prefix")}
|
| 982 |
+
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
|
| 983 |
+
{language.t("provider.connect.oauth.code.visit.link")}
|
| 984 |
+
</ExternalLink>
|
| 985 |
+
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
| 986 |
+
</div>
|
| 987 |
+
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
| 988 |
+
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
| 989 |
+
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
| 990 |
+
<TextInputV2
|
| 991 |
+
ref={codeInput}
|
| 992 |
+
class="!w-full"
|
| 993 |
+
name="code"
|
| 994 |
+
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
| 995 |
+
value={formStore.value}
|
| 996 |
+
invalid={formStore.error !== undefined}
|
| 997 |
+
aria-describedby={formStore.error ? errorID : undefined}
|
| 998 |
+
autocomplete="off"
|
| 999 |
+
spellcheck={false}
|
| 1000 |
+
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
| 1001 |
+
/>
|
| 1002 |
+
</label>
|
| 1003 |
+
<Show when={formStore.error}>
|
| 1004 |
+
{(error) => (
|
| 1005 |
+
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
| 1006 |
+
{error()}
|
| 1007 |
+
</div>
|
| 1008 |
+
)}
|
| 1009 |
+
</Show>
|
| 1010 |
+
<ButtonV2 type="submit" variant="contrast">
|
| 1011 |
+
{language.t("common.continue")}
|
| 1012 |
+
</ButtonV2>
|
| 1013 |
+
</form>
|
| 1014 |
+
</div>
|
| 1015 |
+
)
|
| 1016 |
+
|
| 1017 |
+
return (
|
| 1018 |
+
<div class="flex flex-col gap-6">
|
| 1019 |
+
<div class="text-14-regular text-text-base">
|
| 1020 |
+
{language.t("provider.connect.oauth.code.visit.prefix")}
|
| 1021 |
+
<ExternalLink href={store.authorization!.url}>
|
| 1022 |
+
{language.t("provider.connect.oauth.code.visit.link")}
|
| 1023 |
+
</ExternalLink>
|
| 1024 |
+
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
| 1025 |
+
</div>
|
| 1026 |
+
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
| 1027 |
+
<TextField
|
| 1028 |
+
autofocus={!newLayout()}
|
| 1029 |
+
ref={codeInput}
|
| 1030 |
+
type="text"
|
| 1031 |
+
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
| 1032 |
+
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
| 1033 |
+
name="code"
|
| 1034 |
+
value={formStore.value}
|
| 1035 |
+
onChange={(v) => setFormStore("value", v)}
|
| 1036 |
+
validationState={formStore.error ? "invalid" : undefined}
|
| 1037 |
+
error={formStore.error}
|
| 1038 |
+
/>
|
| 1039 |
+
<Button class="w-auto" type="submit" size="large" variant="primary">
|
| 1040 |
+
{language.t("common.continue")}
|
| 1041 |
+
</Button>
|
| 1042 |
+
</form>
|
| 1043 |
+
</div>
|
| 1044 |
+
)
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
function OAuthAutoView() {
|
| 1048 |
+
const code = createMemo(() => {
|
| 1049 |
+
const instructions = store.authorization?.instructions
|
| 1050 |
+
if (instructions?.includes(":")) {
|
| 1051 |
+
return instructions.split(":").pop()?.trim()
|
| 1052 |
+
}
|
| 1053 |
+
return instructions
|
| 1054 |
+
})
|
| 1055 |
+
|
| 1056 |
+
onMount(() => {
|
| 1057 |
+
const poll = async () => {
|
| 1058 |
+
const authorization = store.authorization
|
| 1059 |
+
if (!authorization || !alive.value) return
|
| 1060 |
+
const result = await serverSDK()
|
| 1061 |
+
.api.integration.oauth.status({
|
| 1062 |
+
integrationID: props.provider,
|
| 1063 |
+
attemptID: authorization.attemptID,
|
| 1064 |
+
location: location(),
|
| 1065 |
+
})
|
| 1066 |
+
.then((value) => ({ ok: true as const, status: value.data }))
|
| 1067 |
+
.catch((error) => ({ ok: false as const, error }))
|
| 1068 |
+
if (!alive.value) return
|
| 1069 |
+
if (!result.ok) {
|
| 1070 |
+
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
| 1071 |
+
return
|
| 1072 |
+
}
|
| 1073 |
+
if (result.status.status === "complete") {
|
| 1074 |
+
await complete()
|
| 1075 |
+
return
|
| 1076 |
+
}
|
| 1077 |
+
if (result.status.status === "failed") {
|
| 1078 |
+
dispatch({ type: "auth.error", error: result.status.message })
|
| 1079 |
+
return
|
| 1080 |
+
}
|
| 1081 |
+
if (result.status.status === "expired") {
|
| 1082 |
+
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
| 1083 |
+
return
|
| 1084 |
+
}
|
| 1085 |
+
timer.current = setTimeout(poll, 1_000)
|
| 1086 |
+
}
|
| 1087 |
+
void poll()
|
| 1088 |
+
})
|
| 1089 |
+
|
| 1090 |
+
return (
|
| 1091 |
+
<div class="flex flex-col gap-6">
|
| 1092 |
+
<div class="text-14-regular text-text-base">
|
| 1093 |
+
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
| 1094 |
+
<ExternalLink href={store.authorization!.url}>
|
| 1095 |
+
{language.t("provider.connect.oauth.auto.visit.link")}
|
| 1096 |
+
</ExternalLink>
|
| 1097 |
+
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
| 1098 |
+
</div>
|
| 1099 |
+
<TextField
|
| 1100 |
+
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
| 1101 |
+
class="font-mono"
|
| 1102 |
+
value={code()}
|
| 1103 |
+
readOnly
|
| 1104 |
+
copyable
|
| 1105 |
+
/>
|
| 1106 |
+
<div class="text-14-regular text-text-base flex items-center gap-4">
|
| 1107 |
+
<Spinner />
|
| 1108 |
+
<span>{language.t("provider.connect.status.waiting")}</span>
|
| 1109 |
+
</div>
|
| 1110 |
+
</div>
|
| 1111 |
+
)
|
| 1112 |
+
}
|
| 1113 |
+
|
| 1114 |
+
return (
|
| 1115 |
+
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
|
| 1116 |
+
<div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}>
|
| 1117 |
+
<ProviderIcon
|
| 1118 |
+
id={props.provider}
|
| 1119 |
+
class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
|
| 1120 |
+
/>
|
| 1121 |
+
<div
|
| 1122 |
+
class={
|
| 1123 |
+
newLayout()
|
| 1124 |
+
? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
|
| 1125 |
+
: "text-16-medium text-text-strong"
|
| 1126 |
+
}
|
| 1127 |
+
>
|
| 1128 |
+
<Switch>
|
| 1129 |
+
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
| 1130 |
+
{language.t("provider.connect.title.anthropicProMax")}
|
| 1131 |
+
</Match>
|
| 1132 |
+
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
|
| 1133 |
+
</Switch>
|
| 1134 |
+
</div>
|
| 1135 |
+
</div>
|
| 1136 |
+
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}>
|
| 1137 |
+
<div
|
| 1138 |
+
onKeyDown={handleKey}
|
| 1139 |
+
tabIndex={newLayout() ? undefined : 0}
|
| 1140 |
+
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
|
| 1141 |
+
>
|
| 1142 |
+
<Switch>
|
| 1143 |
+
<Match when={loading()}>
|
| 1144 |
+
<div class="text-14-regular text-text-base">
|
| 1145 |
+
<div class="flex items-center gap-x-2">
|
| 1146 |
+
<Spinner />
|
| 1147 |
+
<span>{language.t("provider.connect.status.inProgress")}</span>
|
| 1148 |
+
</div>
|
| 1149 |
+
</div>
|
| 1150 |
+
</Match>
|
| 1151 |
+
<Match when={store.methodIndex === undefined}>
|
| 1152 |
+
<MethodSelection />
|
| 1153 |
+
</Match>
|
| 1154 |
+
<Match when={store.state === "pending"}>
|
| 1155 |
+
<div class="text-14-regular text-text-base">
|
| 1156 |
+
<div class="flex items-center gap-x-2">
|
| 1157 |
+
<Spinner />
|
| 1158 |
+
<span>{language.t("provider.connect.status.inProgress")}</span>
|
| 1159 |
+
</div>
|
| 1160 |
+
</div>
|
| 1161 |
+
</Match>
|
| 1162 |
+
<Match when={store.state === "prompt"}>
|
| 1163 |
+
<AuthPromptsView />
|
| 1164 |
+
</Match>
|
| 1165 |
+
<Match when={store.state === "error"}>
|
| 1166 |
+
<div class="text-14-regular text-text-base">
|
| 1167 |
+
<div class="flex items-center gap-x-2">
|
| 1168 |
+
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
| 1169 |
+
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
| 1170 |
+
</div>
|
| 1171 |
+
</div>
|
| 1172 |
+
</Match>
|
| 1173 |
+
<Match when={method()?.type === "key"}>
|
| 1174 |
+
<ApiAuthView />
|
| 1175 |
+
</Match>
|
| 1176 |
+
<Match when={method()?.type === "oauth"}>
|
| 1177 |
+
<Switch>
|
| 1178 |
+
<Match when={store.authorization?.mode === "code"}>
|
| 1179 |
+
<OAuthCodeView />
|
| 1180 |
+
</Match>
|
| 1181 |
+
<Match when={store.authorization?.mode === "auto"}>
|
| 1182 |
+
<OAuthAutoView />
|
| 1183 |
+
</Match>
|
| 1184 |
+
</Switch>
|
| 1185 |
+
</Match>
|
| 1186 |
+
</Switch>
|
| 1187 |
+
</div>
|
| 1188 |
+
</div>
|
| 1189 |
+
</div>
|
| 1190 |
+
)
|
| 1191 |
+
}
|
packages/app/src/components/dialog-custom-provider-form.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
| 2 |
+
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
| 3 |
+
|
| 4 |
+
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
|
| 5 |
+
|
| 6 |
+
export type ModelErr = {
|
| 7 |
+
id?: string
|
| 8 |
+
name?: string
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export type HeaderErr = {
|
| 12 |
+
key?: string
|
| 13 |
+
value?: string
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export type ModelRow = {
|
| 17 |
+
row: string
|
| 18 |
+
id: string
|
| 19 |
+
name: string
|
| 20 |
+
err: ModelErr
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export type HeaderRow = {
|
| 24 |
+
row: string
|
| 25 |
+
key: string
|
| 26 |
+
value: string
|
| 27 |
+
err: HeaderErr
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export type FormState = {
|
| 31 |
+
providerID: string
|
| 32 |
+
name: string
|
| 33 |
+
baseURL: string
|
| 34 |
+
apiKey: string
|
| 35 |
+
models: ModelRow[]
|
| 36 |
+
headers: HeaderRow[]
|
| 37 |
+
err: {
|
| 38 |
+
providerID?: string
|
| 39 |
+
name?: string
|
| 40 |
+
baseURL?: string
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
type ValidateArgs = {
|
| 45 |
+
form: FormState
|
| 46 |
+
t: Translator
|
| 47 |
+
disabledProviders: string[]
|
| 48 |
+
existingProviderIDs: Set<string>
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function validateCustomProvider(input: ValidateArgs) {
|
| 52 |
+
const providerID = input.form.providerID.trim()
|
| 53 |
+
const name = input.form.name.trim()
|
| 54 |
+
const baseURL = input.form.baseURL.trim()
|
| 55 |
+
const apiKey = input.form.apiKey.trim()
|
| 56 |
+
|
| 57 |
+
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
| 58 |
+
const key = apiKey && !env ? apiKey : undefined
|
| 59 |
+
|
| 60 |
+
const idError = !providerID
|
| 61 |
+
? input.t("provider.custom.error.providerID.required")
|
| 62 |
+
: !PROVIDER_ID.test(providerID)
|
| 63 |
+
? input.t("provider.custom.error.providerID.format")
|
| 64 |
+
: undefined
|
| 65 |
+
|
| 66 |
+
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
| 67 |
+
const urlError = !baseURL
|
| 68 |
+
? input.t("provider.custom.error.baseURL.required")
|
| 69 |
+
: !/^https?:\/\//.test(baseURL)
|
| 70 |
+
? input.t("provider.custom.error.baseURL.format")
|
| 71 |
+
: undefined
|
| 72 |
+
|
| 73 |
+
const disabled = input.disabledProviders.includes(providerID)
|
| 74 |
+
const existsError = idError
|
| 75 |
+
? undefined
|
| 76 |
+
: input.existingProviderIDs.has(providerID) && !disabled
|
| 77 |
+
? input.t("provider.custom.error.providerID.exists")
|
| 78 |
+
: undefined
|
| 79 |
+
|
| 80 |
+
const seenModels = new Set<string>()
|
| 81 |
+
const models = input.form.models.map((m) => {
|
| 82 |
+
const id = m.id.trim()
|
| 83 |
+
const idError = !id
|
| 84 |
+
? input.t("provider.custom.error.required")
|
| 85 |
+
: seenModels.has(id)
|
| 86 |
+
? input.t("provider.custom.error.duplicate")
|
| 87 |
+
: (() => {
|
| 88 |
+
seenModels.add(id)
|
| 89 |
+
return undefined
|
| 90 |
+
})()
|
| 91 |
+
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
| 92 |
+
return { id: idError, name: nameError }
|
| 93 |
+
})
|
| 94 |
+
const modelsValid = models.every((m) => !m.id && !m.name)
|
| 95 |
+
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
| 96 |
+
|
| 97 |
+
const seenHeaders = new Set<string>()
|
| 98 |
+
const headers = input.form.headers.map((h) => {
|
| 99 |
+
const key = h.key.trim()
|
| 100 |
+
const value = h.value.trim()
|
| 101 |
+
|
| 102 |
+
if (!key && !value) return {}
|
| 103 |
+
const keyError = !key
|
| 104 |
+
? input.t("provider.custom.error.required")
|
| 105 |
+
: seenHeaders.has(key.toLowerCase())
|
| 106 |
+
? input.t("provider.custom.error.duplicate")
|
| 107 |
+
: (() => {
|
| 108 |
+
seenHeaders.add(key.toLowerCase())
|
| 109 |
+
return undefined
|
| 110 |
+
})()
|
| 111 |
+
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
| 112 |
+
return { key: keyError, value: valueError }
|
| 113 |
+
})
|
| 114 |
+
const headersValid = headers.every((h) => !h.key && !h.value)
|
| 115 |
+
const headerConfig = Object.fromEntries(
|
| 116 |
+
input.form.headers
|
| 117 |
+
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
| 118 |
+
.filter((h) => !!h.key && !!h.value)
|
| 119 |
+
.map((h) => [h.key, h.value]),
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
const err = {
|
| 123 |
+
providerID: idError ?? existsError,
|
| 124 |
+
name: nameError,
|
| 125 |
+
baseURL: urlError,
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
| 129 |
+
if (!ok) return { err, models, headers }
|
| 130 |
+
|
| 131 |
+
return {
|
| 132 |
+
err,
|
| 133 |
+
models,
|
| 134 |
+
headers,
|
| 135 |
+
result: {
|
| 136 |
+
providerID,
|
| 137 |
+
name,
|
| 138 |
+
key,
|
| 139 |
+
config: {
|
| 140 |
+
npm: OPENAI_COMPATIBLE,
|
| 141 |
+
name,
|
| 142 |
+
...(env ? { env: [env] } : {}),
|
| 143 |
+
options: {
|
| 144 |
+
baseURL,
|
| 145 |
+
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
|
| 146 |
+
},
|
| 147 |
+
models: modelConfig,
|
| 148 |
+
},
|
| 149 |
+
},
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
let row = 0
|
| 154 |
+
|
| 155 |
+
const nextRow = () => `row-${row++}`
|
| 156 |
+
|
| 157 |
+
export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
|
| 158 |
+
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
|
packages/app/src/components/dialog-custom-provider.test.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, test } from "bun:test"
|
| 2 |
+
import { validateCustomProvider } from "./dialog-custom-provider-form"
|
| 3 |
+
|
| 4 |
+
const t = (key: string) => key
|
| 5 |
+
|
| 6 |
+
describe("validateCustomProvider", () => {
|
| 7 |
+
test("builds trimmed config payload", () => {
|
| 8 |
+
const result = validateCustomProvider({
|
| 9 |
+
form: {
|
| 10 |
+
providerID: "custom-provider",
|
| 11 |
+
name: " Custom Provider ",
|
| 12 |
+
baseURL: "https://api.example.com ",
|
| 13 |
+
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
|
| 14 |
+
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
|
| 15 |
+
headers: [
|
| 16 |
+
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
| 17 |
+
{ row: "h1", key: "", value: "", err: {} },
|
| 18 |
+
],
|
| 19 |
+
err: {},
|
| 20 |
+
},
|
| 21 |
+
t,
|
| 22 |
+
disabledProviders: [],
|
| 23 |
+
existingProviderIDs: new Set(),
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
expect(result.result).toEqual({
|
| 27 |
+
providerID: "custom-provider",
|
| 28 |
+
name: "Custom Provider",
|
| 29 |
+
key: undefined,
|
| 30 |
+
config: {
|
| 31 |
+
npm: "@ai-sdk/openai-compatible",
|
| 32 |
+
name: "Custom Provider",
|
| 33 |
+
env: ["CUSTOM_PROVIDER_KEY"],
|
| 34 |
+
options: {
|
| 35 |
+
baseURL: "https://api.example.com",
|
| 36 |
+
headers: {
|
| 37 |
+
"X-Test": "enabled",
|
| 38 |
+
},
|
| 39 |
+
},
|
| 40 |
+
models: {
|
| 41 |
+
"model-a": { name: "Model A" },
|
| 42 |
+
},
|
| 43 |
+
},
|
| 44 |
+
})
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
test("flags duplicate rows and allows reconnecting disabled providers", () => {
|
| 48 |
+
const result = validateCustomProvider({
|
| 49 |
+
form: {
|
| 50 |
+
providerID: "custom-provider",
|
| 51 |
+
name: "Provider",
|
| 52 |
+
baseURL: "https://api.example.com",
|
| 53 |
+
apiKey: "secret",
|
| 54 |
+
models: [
|
| 55 |
+
{ row: "m0", id: "model-a", name: "Model A", err: {} },
|
| 56 |
+
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
|
| 57 |
+
],
|
| 58 |
+
headers: [
|
| 59 |
+
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
| 60 |
+
{ row: "h1", key: "authorization", value: "two", err: {} },
|
| 61 |
+
],
|
| 62 |
+
err: {},
|
| 63 |
+
},
|
| 64 |
+
t,
|
| 65 |
+
disabledProviders: ["custom-provider"],
|
| 66 |
+
existingProviderIDs: new Set(["custom-provider"]),
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
expect(result.result).toBeUndefined()
|
| 70 |
+
expect(result.err.providerID).toBeUndefined()
|
| 71 |
+
expect(result.models[1]).toEqual({
|
| 72 |
+
id: "provider.custom.error.duplicate",
|
| 73 |
+
name: undefined,
|
| 74 |
+
})
|
| 75 |
+
expect(result.headers[1]).toEqual({
|
| 76 |
+
key: "provider.custom.error.duplicate",
|
| 77 |
+
value: undefined,
|
| 78 |
+
})
|
| 79 |
+
})
|
| 80 |
+
})
|
packages/app/src/components/dialog-custom-provider.tsx
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from "@opencode-ai/ui/button"
|
| 2 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 3 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 4 |
+
import { IconButton } from "@opencode-ai/ui/icon-button"
|
| 5 |
+
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
| 6 |
+
import { useMutation } from "@tanstack/solid-query"
|
| 7 |
+
import { TextField } from "@opencode-ai/ui/text-field"
|
| 8 |
+
import { showToast } from "@/utils/toast"
|
| 9 |
+
import { batch, For } from "solid-js"
|
| 10 |
+
import { createStore, produce } from "solid-js/store"
|
| 11 |
+
import { ExternalLink } from "@/components/external-link"
|
| 12 |
+
import { useServerSDK } from "@/context/server-sdk"
|
| 13 |
+
import { useServerSync } from "@/context/server-sync"
|
| 14 |
+
import { useLanguage } from "@/context/language"
|
| 15 |
+
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
| 16 |
+
|
| 17 |
+
type Props = {
|
| 18 |
+
onBack: () => void
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function DialogCustomProvider(props: Props) {
|
| 22 |
+
const language = useLanguage()
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<Dialog
|
| 26 |
+
class="h-full"
|
| 27 |
+
title={
|
| 28 |
+
<IconButton
|
| 29 |
+
tabIndex={-1}
|
| 30 |
+
icon="arrow-left"
|
| 31 |
+
variant="ghost"
|
| 32 |
+
onClick={props.onBack}
|
| 33 |
+
aria-label={language.t("common.goBack")}
|
| 34 |
+
/>
|
| 35 |
+
}
|
| 36 |
+
transition
|
| 37 |
+
>
|
| 38 |
+
<CustomProviderForm />
|
| 39 |
+
</Dialog>
|
| 40 |
+
)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
| 44 |
+
const dialog = useDialog()
|
| 45 |
+
const serverSync = useServerSync()
|
| 46 |
+
const serverSDK = useServerSDK()
|
| 47 |
+
const language = useLanguage()
|
| 48 |
+
|
| 49 |
+
const [form, setForm] = createStore<FormState>({
|
| 50 |
+
providerID: "",
|
| 51 |
+
name: "",
|
| 52 |
+
baseURL: "",
|
| 53 |
+
apiKey: "",
|
| 54 |
+
models: [modelRow()],
|
| 55 |
+
headers: [headerRow()],
|
| 56 |
+
err: {},
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
const addModel = () => {
|
| 60 |
+
setForm(
|
| 61 |
+
"models",
|
| 62 |
+
produce((rows) => {
|
| 63 |
+
rows.push(modelRow())
|
| 64 |
+
}),
|
| 65 |
+
)
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const removeModel = (index: number) => {
|
| 69 |
+
if (form.models.length <= 1) return
|
| 70 |
+
setForm(
|
| 71 |
+
"models",
|
| 72 |
+
produce((rows) => {
|
| 73 |
+
rows.splice(index, 1)
|
| 74 |
+
}),
|
| 75 |
+
)
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const addHeader = () => {
|
| 79 |
+
setForm(
|
| 80 |
+
"headers",
|
| 81 |
+
produce((rows) => {
|
| 82 |
+
rows.push(headerRow())
|
| 83 |
+
}),
|
| 84 |
+
)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const removeHeader = (index: number) => {
|
| 88 |
+
if (form.headers.length <= 1) return
|
| 89 |
+
setForm(
|
| 90 |
+
"headers",
|
| 91 |
+
produce((rows) => {
|
| 92 |
+
rows.splice(index, 1)
|
| 93 |
+
}),
|
| 94 |
+
)
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
|
| 98 |
+
setForm(key, value)
|
| 99 |
+
if (key === "apiKey") return
|
| 100 |
+
setForm("err", key, undefined)
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const setModel = (index: number, key: "id" | "name", value: string) => {
|
| 104 |
+
batch(() => {
|
| 105 |
+
setForm("models", index, key, value)
|
| 106 |
+
setForm("models", index, "err", key, undefined)
|
| 107 |
+
})
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const setHeader = (index: number, key: "key" | "value", value: string) => {
|
| 111 |
+
batch(() => {
|
| 112 |
+
setForm("headers", index, key, value)
|
| 113 |
+
setForm("headers", index, "err", key, undefined)
|
| 114 |
+
})
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const validate = () => {
|
| 118 |
+
const output = validateCustomProvider({
|
| 119 |
+
form,
|
| 120 |
+
t: language.t,
|
| 121 |
+
disabledProviders: serverSync().data.config.disabled_providers ?? [],
|
| 122 |
+
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
|
| 123 |
+
})
|
| 124 |
+
batch(() => {
|
| 125 |
+
setForm("err", output.err)
|
| 126 |
+
output.models.forEach((err, index) => setForm("models", index, "err", err))
|
| 127 |
+
output.headers.forEach((err, index) => setForm("headers", index, "err", err))
|
| 128 |
+
})
|
| 129 |
+
return output.result
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const saveMutation = useMutation(() => ({
|
| 133 |
+
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
| 134 |
+
if ((await serverSDK().protocol) !== "v1") throw new Error(language.t("provider.custom.unavailable"))
|
| 135 |
+
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
| 136 |
+
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
| 137 |
+
|
| 138 |
+
if (result.key) {
|
| 139 |
+
await serverSDK().client.auth.set({
|
| 140 |
+
providerID: result.providerID,
|
| 141 |
+
auth: {
|
| 142 |
+
type: "api",
|
| 143 |
+
key: result.key,
|
| 144 |
+
},
|
| 145 |
+
})
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
await serverSync().updateConfig({
|
| 149 |
+
provider: { [result.providerID]: result.config },
|
| 150 |
+
disabled_providers: nextDisabled,
|
| 151 |
+
})
|
| 152 |
+
return result
|
| 153 |
+
},
|
| 154 |
+
onSuccess: (result) => {
|
| 155 |
+
dialog.close()
|
| 156 |
+
showToast({
|
| 157 |
+
variant: "success",
|
| 158 |
+
icon: "circle-check",
|
| 159 |
+
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
| 160 |
+
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
| 161 |
+
})
|
| 162 |
+
},
|
| 163 |
+
onError: (err) => {
|
| 164 |
+
const message = err instanceof Error ? err.message : String(err)
|
| 165 |
+
showToast({ title: language.t("common.requestFailed"), description: message })
|
| 166 |
+
},
|
| 167 |
+
}))
|
| 168 |
+
|
| 169 |
+
const save = (e: SubmitEvent) => {
|
| 170 |
+
e.preventDefault()
|
| 171 |
+
if (saveMutation.isPending) return
|
| 172 |
+
|
| 173 |
+
const result = validate()
|
| 174 |
+
if (!result) return
|
| 175 |
+
saveMutation.mutate(result)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
return (
|
| 179 |
+
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
| 180 |
+
<div class="px-2.5 flex gap-4 items-center">
|
| 181 |
+
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
| 182 |
+
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
| 186 |
+
<p class="text-14-regular text-text-base">
|
| 187 |
+
{language.t("provider.custom.description.prefix")}
|
| 188 |
+
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
| 189 |
+
{language.t("provider.custom.description.link")}
|
| 190 |
+
</ExternalLink>
|
| 191 |
+
{language.t("provider.custom.description.suffix")}
|
| 192 |
+
</p>
|
| 193 |
+
|
| 194 |
+
<div class="flex flex-col gap-4">
|
| 195 |
+
<TextField
|
| 196 |
+
autofocus={props.autofocus ?? true}
|
| 197 |
+
label={language.t("provider.custom.field.providerID.label")}
|
| 198 |
+
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
| 199 |
+
description={language.t("provider.custom.field.providerID.description")}
|
| 200 |
+
value={form.providerID}
|
| 201 |
+
onChange={(v) => setField("providerID", v)}
|
| 202 |
+
validationState={form.err.providerID ? "invalid" : undefined}
|
| 203 |
+
error={form.err.providerID}
|
| 204 |
+
/>
|
| 205 |
+
<TextField
|
| 206 |
+
label={language.t("provider.custom.field.name.label")}
|
| 207 |
+
placeholder={language.t("provider.custom.field.name.placeholder")}
|
| 208 |
+
value={form.name}
|
| 209 |
+
onChange={(v) => setField("name", v)}
|
| 210 |
+
validationState={form.err.name ? "invalid" : undefined}
|
| 211 |
+
error={form.err.name}
|
| 212 |
+
/>
|
| 213 |
+
<TextField
|
| 214 |
+
label={language.t("provider.custom.field.baseURL.label")}
|
| 215 |
+
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
| 216 |
+
value={form.baseURL}
|
| 217 |
+
onChange={(v) => setField("baseURL", v)}
|
| 218 |
+
validationState={form.err.baseURL ? "invalid" : undefined}
|
| 219 |
+
error={form.err.baseURL}
|
| 220 |
+
/>
|
| 221 |
+
<TextField
|
| 222 |
+
label={language.t("provider.custom.field.apiKey.label")}
|
| 223 |
+
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
| 224 |
+
description={language.t("provider.custom.field.apiKey.description")}
|
| 225 |
+
value={form.apiKey}
|
| 226 |
+
onChange={(v) => setField("apiKey", v)}
|
| 227 |
+
/>
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
<div class="flex flex-col gap-3">
|
| 231 |
+
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
| 232 |
+
<For each={form.models}>
|
| 233 |
+
{(m, i) => (
|
| 234 |
+
<div class="flex gap-2 items-start" data-row={m.row}>
|
| 235 |
+
<div class="flex-1">
|
| 236 |
+
<TextField
|
| 237 |
+
label={language.t("provider.custom.models.id.label")}
|
| 238 |
+
hideLabel
|
| 239 |
+
placeholder={language.t("provider.custom.models.id.placeholder")}
|
| 240 |
+
value={m.id}
|
| 241 |
+
onChange={(v) => setModel(i(), "id", v)}
|
| 242 |
+
validationState={m.err.id ? "invalid" : undefined}
|
| 243 |
+
error={m.err.id}
|
| 244 |
+
/>
|
| 245 |
+
</div>
|
| 246 |
+
<div class="flex-1">
|
| 247 |
+
<TextField
|
| 248 |
+
label={language.t("provider.custom.models.name.label")}
|
| 249 |
+
hideLabel
|
| 250 |
+
placeholder={language.t("provider.custom.models.name.placeholder")}
|
| 251 |
+
value={m.name}
|
| 252 |
+
onChange={(v) => setModel(i(), "name", v)}
|
| 253 |
+
validationState={m.err.name ? "invalid" : undefined}
|
| 254 |
+
error={m.err.name}
|
| 255 |
+
/>
|
| 256 |
+
</div>
|
| 257 |
+
<IconButton
|
| 258 |
+
type="button"
|
| 259 |
+
icon="trash"
|
| 260 |
+
variant="ghost"
|
| 261 |
+
class="mt-1.5"
|
| 262 |
+
onClick={() => removeModel(i())}
|
| 263 |
+
disabled={form.models.length <= 1}
|
| 264 |
+
aria-label={language.t("provider.custom.models.remove")}
|
| 265 |
+
/>
|
| 266 |
+
</div>
|
| 267 |
+
)}
|
| 268 |
+
</For>
|
| 269 |
+
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
| 270 |
+
{language.t("provider.custom.models.add")}
|
| 271 |
+
</Button>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
<div class="flex flex-col gap-3">
|
| 275 |
+
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
| 276 |
+
<For each={form.headers}>
|
| 277 |
+
{(h, i) => (
|
| 278 |
+
<div class="flex gap-2 items-start" data-row={h.row}>
|
| 279 |
+
<div class="flex-1">
|
| 280 |
+
<TextField
|
| 281 |
+
label={language.t("provider.custom.headers.key.label")}
|
| 282 |
+
hideLabel
|
| 283 |
+
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
| 284 |
+
value={h.key}
|
| 285 |
+
onChange={(v) => setHeader(i(), "key", v)}
|
| 286 |
+
validationState={h.err.key ? "invalid" : undefined}
|
| 287 |
+
error={h.err.key}
|
| 288 |
+
/>
|
| 289 |
+
</div>
|
| 290 |
+
<div class="flex-1">
|
| 291 |
+
<TextField
|
| 292 |
+
label={language.t("provider.custom.headers.value.label")}
|
| 293 |
+
hideLabel
|
| 294 |
+
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
| 295 |
+
value={h.value}
|
| 296 |
+
onChange={(v) => setHeader(i(), "value", v)}
|
| 297 |
+
validationState={h.err.value ? "invalid" : undefined}
|
| 298 |
+
error={h.err.value}
|
| 299 |
+
/>
|
| 300 |
+
</div>
|
| 301 |
+
<IconButton
|
| 302 |
+
type="button"
|
| 303 |
+
icon="trash"
|
| 304 |
+
variant="ghost"
|
| 305 |
+
class="mt-1.5"
|
| 306 |
+
onClick={() => removeHeader(i())}
|
| 307 |
+
disabled={form.headers.length <= 1}
|
| 308 |
+
aria-label={language.t("provider.custom.headers.remove")}
|
| 309 |
+
/>
|
| 310 |
+
</div>
|
| 311 |
+
)}
|
| 312 |
+
</For>
|
| 313 |
+
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
| 314 |
+
{language.t("provider.custom.headers.add")}
|
| 315 |
+
</Button>
|
| 316 |
+
</div>
|
| 317 |
+
|
| 318 |
+
<Button
|
| 319 |
+
class="w-auto self-start"
|
| 320 |
+
type="submit"
|
| 321 |
+
size="large"
|
| 322 |
+
variant="primary"
|
| 323 |
+
disabled={saveMutation.isPending}
|
| 324 |
+
>
|
| 325 |
+
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
| 326 |
+
</Button>
|
| 327 |
+
</form>
|
| 328 |
+
</div>
|
| 329 |
+
)
|
| 330 |
+
}
|
packages/app/src/components/dialog-edit-project-v2.tsx
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
| 2 |
+
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
| 3 |
+
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
| 4 |
+
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
| 5 |
+
import { Icon } from "@opencode-ai/ui/v2/icon"
|
| 6 |
+
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
| 7 |
+
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
| 8 |
+
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
| 9 |
+
import { For, Show } from "solid-js"
|
| 10 |
+
import { useLanguage } from "@/context/language"
|
| 11 |
+
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
| 12 |
+
import { ServerConnection } from "@/context/server"
|
| 13 |
+
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
| 14 |
+
import { createEditProjectModel } from "./edit-project"
|
| 15 |
+
|
| 16 |
+
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
| 17 |
+
const language = useLanguage()
|
| 18 |
+
const model = createEditProjectModel(props)
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<Dialog fit>
|
| 22 |
+
<form onSubmit={model.submit} class="contents">
|
| 23 |
+
<DialogHeader>
|
| 24 |
+
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
| 25 |
+
</DialogHeader>
|
| 26 |
+
<DividerV2 />
|
| 27 |
+
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
| 28 |
+
<Field>
|
| 29 |
+
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
| 30 |
+
<TextInputV2
|
| 31 |
+
autofocus
|
| 32 |
+
appearance="large"
|
| 33 |
+
class="!w-full"
|
| 34 |
+
value={model.store.name}
|
| 35 |
+
placeholder={model.folderName()}
|
| 36 |
+
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
| 37 |
+
/>
|
| 38 |
+
</Field>
|
| 39 |
+
|
| 40 |
+
<div class="flex w-full flex-col gap-2">
|
| 41 |
+
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
| 42 |
+
{language.t("dialog.project.edit.icon")}
|
| 43 |
+
</div>
|
| 44 |
+
<div class="flex items-center gap-3">
|
| 45 |
+
<button
|
| 46 |
+
type="button"
|
| 47 |
+
aria-label={language.t("dialog.project.edit.icon.alt")}
|
| 48 |
+
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
| 49 |
+
classList={{
|
| 50 |
+
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
| 51 |
+
}}
|
| 52 |
+
onMouseEnter={() => model.setStore("iconHover", true)}
|
| 53 |
+
onMouseLeave={() => model.setStore("iconHover", false)}
|
| 54 |
+
onDrop={model.drop}
|
| 55 |
+
onDragOver={model.dragOver}
|
| 56 |
+
onDragLeave={model.dragLeave}
|
| 57 |
+
onClick={model.iconClick}
|
| 58 |
+
>
|
| 59 |
+
<ProjectAvatar
|
| 60 |
+
fallback={model.store.name || model.defaultName()}
|
| 61 |
+
src={getProjectAvatarSource(props.project.id, {
|
| 62 |
+
color: model.store.color,
|
| 63 |
+
url: props.project.icon?.url,
|
| 64 |
+
override: model.store.iconOverride,
|
| 65 |
+
})}
|
| 66 |
+
variant={getProjectAvatarVariant(model.store.color)}
|
| 67 |
+
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
| 68 |
+
/>
|
| 69 |
+
<span
|
| 70 |
+
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
| 71 |
+
classList={{
|
| 72 |
+
"opacity-100": model.store.iconHover,
|
| 73 |
+
"opacity-0": !model.store.iconHover,
|
| 74 |
+
}}
|
| 75 |
+
>
|
| 76 |
+
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
| 77 |
+
</span>
|
| 78 |
+
</button>
|
| 79 |
+
<input
|
| 80 |
+
ref={(element) => {
|
| 81 |
+
model.setIconInput(element)
|
| 82 |
+
}}
|
| 83 |
+
type="file"
|
| 84 |
+
accept="image/*"
|
| 85 |
+
class="hidden"
|
| 86 |
+
onChange={model.inputChange}
|
| 87 |
+
/>
|
| 88 |
+
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
| 89 |
+
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
| 90 |
+
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<Show when={!model.store.iconOverride}>
|
| 96 |
+
<div class="flex w-full flex-col gap-2">
|
| 97 |
+
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
| 98 |
+
{language.t("dialog.project.edit.color")}
|
| 99 |
+
</div>
|
| 100 |
+
<div class="-ml-1 flex gap-1.5">
|
| 101 |
+
<For each={PROJECT_AVATAR_VARIANTS}>
|
| 102 |
+
{(color) => (
|
| 103 |
+
<button
|
| 104 |
+
type="button"
|
| 105 |
+
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
| 106 |
+
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
| 107 |
+
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
| 108 |
+
classList={{
|
| 109 |
+
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
| 110 |
+
getProjectAvatarVariant(model.store.color) === color,
|
| 111 |
+
}}
|
| 112 |
+
onClick={() => {
|
| 113 |
+
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
| 114 |
+
model.setStore(
|
| 115 |
+
"color",
|
| 116 |
+
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
| 117 |
+
)
|
| 118 |
+
}}
|
| 119 |
+
>
|
| 120 |
+
<ProjectAvatar
|
| 121 |
+
fallback={model.store.name || model.defaultName()}
|
| 122 |
+
variant={getProjectAvatarVariant(color)}
|
| 123 |
+
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
| 124 |
+
/>
|
| 125 |
+
</button>
|
| 126 |
+
)}
|
| 127 |
+
</For>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
</Show>
|
| 131 |
+
|
| 132 |
+
<Field>
|
| 133 |
+
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
| 134 |
+
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
| 135 |
+
<TextareaV2
|
| 136 |
+
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
| 137 |
+
rows={3}
|
| 138 |
+
value={model.store.startup}
|
| 139 |
+
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
| 140 |
+
spellcheck={false}
|
| 141 |
+
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
| 142 |
+
/>
|
| 143 |
+
</Field>
|
| 144 |
+
</DialogBody>
|
| 145 |
+
<DialogFooter>
|
| 146 |
+
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
| 147 |
+
{language.t("common.cancel")}
|
| 148 |
+
</ButtonV2>
|
| 149 |
+
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
|
| 150 |
+
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
| 151 |
+
</ButtonV2>
|
| 152 |
+
</DialogFooter>
|
| 153 |
+
</form>
|
| 154 |
+
</Dialog>
|
| 155 |
+
)
|
| 156 |
+
}
|
packages/app/src/components/dialog-edit-project.tsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from "@opencode-ai/ui/button"
|
| 2 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 3 |
+
import { TextField } from "@opencode-ai/ui/text-field"
|
| 4 |
+
import { Icon } from "@opencode-ai/ui/icon"
|
| 5 |
+
import { For, Show } from "solid-js"
|
| 6 |
+
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
| 7 |
+
import { Avatar } from "@opencode-ai/ui/avatar"
|
| 8 |
+
import { useLanguage } from "@/context/language"
|
| 9 |
+
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
| 10 |
+
import { ServerConnection } from "@/context/server"
|
| 11 |
+
import { createEditProjectModel } from "./edit-project"
|
| 12 |
+
|
| 13 |
+
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
| 14 |
+
|
| 15 |
+
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
| 16 |
+
const language = useLanguage()
|
| 17 |
+
const model = createEditProjectModel(props)
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
| 21 |
+
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
|
| 22 |
+
<div class="flex flex-col gap-4">
|
| 23 |
+
<TextField
|
| 24 |
+
autofocus
|
| 25 |
+
type="text"
|
| 26 |
+
label={language.t("dialog.project.edit.name")}
|
| 27 |
+
placeholder={model.folderName()}
|
| 28 |
+
value={model.store.name}
|
| 29 |
+
onChange={(v) => model.setStore("name", v)}
|
| 30 |
+
/>
|
| 31 |
+
|
| 32 |
+
<div class="flex flex-col gap-2">
|
| 33 |
+
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
|
| 34 |
+
<div class="flex gap-3 items-start">
|
| 35 |
+
<div
|
| 36 |
+
class="relative"
|
| 37 |
+
onMouseEnter={() => model.setStore("iconHover", true)}
|
| 38 |
+
onMouseLeave={() => model.setStore("iconHover", false)}
|
| 39 |
+
>
|
| 40 |
+
<div
|
| 41 |
+
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
| 42 |
+
classList={{
|
| 43 |
+
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
|
| 44 |
+
"border-border-base hover:border-border-strong": !model.store.dragOver,
|
| 45 |
+
"overflow-hidden": !!model.store.iconOverride,
|
| 46 |
+
}}
|
| 47 |
+
onDrop={model.drop}
|
| 48 |
+
onDragOver={model.dragOver}
|
| 49 |
+
onDragLeave={model.dragLeave}
|
| 50 |
+
onClick={model.iconClick}
|
| 51 |
+
>
|
| 52 |
+
<Show
|
| 53 |
+
when={getProjectAvatarSource(props.project.id, {
|
| 54 |
+
color: model.store.color,
|
| 55 |
+
url: props.project.icon?.url,
|
| 56 |
+
override: model.store.iconOverride,
|
| 57 |
+
})}
|
| 58 |
+
fallback={
|
| 59 |
+
<div class="size-full flex items-center justify-center">
|
| 60 |
+
<Avatar
|
| 61 |
+
fallback={model.store.name || model.defaultName()}
|
| 62 |
+
{...getAvatarColors(model.store.color)}
|
| 63 |
+
class="size-full text-[32px]"
|
| 64 |
+
/>
|
| 65 |
+
</div>
|
| 66 |
+
}
|
| 67 |
+
>
|
| 68 |
+
{(src) => (
|
| 69 |
+
<img
|
| 70 |
+
src={src()}
|
| 71 |
+
alt={language.t("dialog.project.edit.icon.alt")}
|
| 72 |
+
class="size-full object-cover"
|
| 73 |
+
/>
|
| 74 |
+
)}
|
| 75 |
+
</Show>
|
| 76 |
+
</div>
|
| 77 |
+
<div
|
| 78 |
+
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
| 79 |
+
classList={{
|
| 80 |
+
"opacity-100": model.store.iconHover && !model.store.iconOverride,
|
| 81 |
+
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
|
| 82 |
+
}}
|
| 83 |
+
>
|
| 84 |
+
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
| 85 |
+
</div>
|
| 86 |
+
<div
|
| 87 |
+
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
| 88 |
+
classList={{
|
| 89 |
+
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
|
| 90 |
+
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
|
| 91 |
+
}}
|
| 92 |
+
>
|
| 93 |
+
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
<input
|
| 97 |
+
id="icon-upload"
|
| 98 |
+
ref={(el) => {
|
| 99 |
+
model.setIconInput(el)
|
| 100 |
+
}}
|
| 101 |
+
type="file"
|
| 102 |
+
accept="image/*"
|
| 103 |
+
class="hidden"
|
| 104 |
+
onChange={model.inputChange}
|
| 105 |
+
/>
|
| 106 |
+
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
| 107 |
+
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
| 108 |
+
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<Show when={!model.store.iconOverride}>
|
| 114 |
+
<div class="flex flex-col gap-2">
|
| 115 |
+
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
| 116 |
+
<div class="flex gap-1.5">
|
| 117 |
+
<For each={AVATAR_COLOR_KEYS}>
|
| 118 |
+
{(color) => (
|
| 119 |
+
<button
|
| 120 |
+
type="button"
|
| 121 |
+
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
| 122 |
+
aria-pressed={model.store.color === color}
|
| 123 |
+
classList={{
|
| 124 |
+
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
| 125 |
+
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
| 126 |
+
model.store.color === color,
|
| 127 |
+
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
| 128 |
+
model.store.color !== color,
|
| 129 |
+
}}
|
| 130 |
+
onClick={() => {
|
| 131 |
+
if (model.store.color === color && !props.project.icon?.url) return
|
| 132 |
+
model.setStore("color", model.store.color === color ? undefined : color)
|
| 133 |
+
}}
|
| 134 |
+
>
|
| 135 |
+
<Avatar
|
| 136 |
+
fallback={model.store.name || model.defaultName()}
|
| 137 |
+
{...getAvatarColors(color)}
|
| 138 |
+
class="size-full rounded"
|
| 139 |
+
/>
|
| 140 |
+
</button>
|
| 141 |
+
)}
|
| 142 |
+
</For>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</Show>
|
| 146 |
+
|
| 147 |
+
<TextField
|
| 148 |
+
multiline
|
| 149 |
+
label={language.t("dialog.project.edit.worktree.startup")}
|
| 150 |
+
description={language.t("dialog.project.edit.worktree.startup.description")}
|
| 151 |
+
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
| 152 |
+
value={model.store.startup}
|
| 153 |
+
onChange={(v) => model.setStore("startup", v)}
|
| 154 |
+
spellcheck={false}
|
| 155 |
+
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
| 156 |
+
/>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<div class="flex justify-end gap-2">
|
| 160 |
+
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
| 161 |
+
{language.t("common.cancel")}
|
| 162 |
+
</Button>
|
| 163 |
+
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
|
| 164 |
+
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
| 165 |
+
</Button>
|
| 166 |
+
</div>
|
| 167 |
+
</form>
|
| 168 |
+
</Dialog>
|
| 169 |
+
)
|
| 170 |
+
}
|
packages/app/src/components/dialog-fork.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Component, createMemo } from "solid-js"
|
| 2 |
+
import { useNavigate, useParams } from "@solidjs/router"
|
| 3 |
+
import { useSync } from "@/context/sync"
|
| 4 |
+
import { useSDK } from "@/context/sdk"
|
| 5 |
+
import { usePrompt } from "@/context/prompt"
|
| 6 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 7 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 8 |
+
import { List } from "@opencode-ai/ui/list"
|
| 9 |
+
import { showToast } from "@/utils/toast"
|
| 10 |
+
import { extractPromptFromParts } from "@/utils/prompt"
|
| 11 |
+
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
| 12 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 13 |
+
import { useLanguage } from "@/context/language"
|
| 14 |
+
|
| 15 |
+
interface ForkableMessage {
|
| 16 |
+
id: string
|
| 17 |
+
text: string
|
| 18 |
+
time: string
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function formatTime(date: Date): string {
|
| 22 |
+
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export const DialogFork: Component = () => {
|
| 26 |
+
const params = useParams()
|
| 27 |
+
const navigate = useNavigate()
|
| 28 |
+
const sync = useSync()
|
| 29 |
+
const sdk = useSDK()
|
| 30 |
+
const prompt = usePrompt()
|
| 31 |
+
const dialog = useDialog()
|
| 32 |
+
const language = useLanguage()
|
| 33 |
+
|
| 34 |
+
const messages = createMemo((): ForkableMessage[] => {
|
| 35 |
+
const sessionID = params.id
|
| 36 |
+
if (!sessionID) return []
|
| 37 |
+
|
| 38 |
+
const msgs = sync().data.message[sessionID] ?? []
|
| 39 |
+
const result: ForkableMessage[] = []
|
| 40 |
+
|
| 41 |
+
for (const message of msgs) {
|
| 42 |
+
if (message.role !== "user") continue
|
| 43 |
+
|
| 44 |
+
const parts = sync().data.part[message.id] ?? []
|
| 45 |
+
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
| 46 |
+
if (!textPart) continue
|
| 47 |
+
|
| 48 |
+
result.push({
|
| 49 |
+
id: message.id,
|
| 50 |
+
text: textPart.text.replace(/\n/g, " ").slice(0, 200),
|
| 51 |
+
time: formatTime(new Date(message.time.created)),
|
| 52 |
+
})
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
return result.reverse()
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
const handleSelect = (item: ForkableMessage | undefined) => {
|
| 59 |
+
if (!item) return
|
| 60 |
+
|
| 61 |
+
const sessionID = params.id
|
| 62 |
+
if (!sessionID) return
|
| 63 |
+
|
| 64 |
+
const parts = sync().data.part[item.id] ?? []
|
| 65 |
+
const restored = extractPromptFromParts(parts, {
|
| 66 |
+
directory: sdk().directory,
|
| 67 |
+
attachmentName: language.t("common.attachment"),
|
| 68 |
+
})
|
| 69 |
+
const dir = base64Encode(sdk().directory)
|
| 70 |
+
|
| 71 |
+
sdk()
|
| 72 |
+
.api.session.fork({ sessionID, messageID: item.id })
|
| 73 |
+
.then((forked) => {
|
| 74 |
+
dialog.close()
|
| 75 |
+
prompt.set(restored, undefined, { dir, id: forked.id })
|
| 76 |
+
navigate(`/${dir}/session/${forked.id}`)
|
| 77 |
+
})
|
| 78 |
+
.catch((err: unknown) => {
|
| 79 |
+
const message = err instanceof Error ? err.message : String(err)
|
| 80 |
+
showToast({ title: language.t("common.requestFailed"), description: message })
|
| 81 |
+
})
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
return (
|
| 85 |
+
<Dialog title={language.t("command.session.fork")}>
|
| 86 |
+
<List
|
| 87 |
+
class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
|
| 88 |
+
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
|
| 89 |
+
emptyMessage={language.t("dialog.fork.empty")}
|
| 90 |
+
key={(x) => x.id}
|
| 91 |
+
items={messages}
|
| 92 |
+
filterKeys={["text"]}
|
| 93 |
+
onSelect={handleSelect}
|
| 94 |
+
>
|
| 95 |
+
{(item) => (
|
| 96 |
+
<div class="w-full flex items-center gap-2">
|
| 97 |
+
<span class="truncate flex-1 min-w-0 text-left font-normal">{item.text}</span>
|
| 98 |
+
<span class="text-text-weak shrink-0 font-normal">{item.time}</span>
|
| 99 |
+
</div>
|
| 100 |
+
)}
|
| 101 |
+
</List>
|
| 102 |
+
</Dialog>
|
| 103 |
+
)
|
| 104 |
+
}
|
packages/app/src/components/dialog-manage-models.tsx
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 2 |
+
import { List } from "@opencode-ai/ui/list"
|
| 3 |
+
import { Switch } from "@opencode-ai/ui/switch"
|
| 4 |
+
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
| 5 |
+
import { Button } from "@opencode-ai/ui/button"
|
| 6 |
+
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
| 7 |
+
import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
| 8 |
+
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
| 9 |
+
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
| 10 |
+
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
| 11 |
+
import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2"
|
| 12 |
+
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
| 13 |
+
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
| 14 |
+
import { For, Show, type Component } from "solid-js"
|
| 15 |
+
import { useLocal } from "@/context/local"
|
| 16 |
+
import { popularProviders } from "@/hooks/use-providers"
|
| 17 |
+
import { useLanguage } from "@/context/language"
|
| 18 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 19 |
+
import { DialogConnectProvider } from "./dialog-connect-provider"
|
| 20 |
+
import { decode64 } from "@/utils/base64"
|
| 21 |
+
import { SettingsListV2 } from "./settings-v2/parts/list"
|
| 22 |
+
import { SettingsRowV2 } from "./settings-v2/parts/row"
|
| 23 |
+
import "./settings-v2/settings-v2.css"
|
| 24 |
+
|
| 25 |
+
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
|
| 26 |
+
|
| 27 |
+
export const DialogManageModels: Component = () => {
|
| 28 |
+
const local = useLocal()
|
| 29 |
+
const language = useLanguage()
|
| 30 |
+
const dialog = useDialog()
|
| 31 |
+
const directory = () => decode64(local.slug())
|
| 32 |
+
|
| 33 |
+
const handleConnectProvider = () => {
|
| 34 |
+
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
| 35 |
+
}
|
| 36 |
+
const providerRank = (id: string) => popularProviders.indexOf(id)
|
| 37 |
+
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
| 38 |
+
const providerVisible = (providerID: string) =>
|
| 39 |
+
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
|
| 40 |
+
const setProviderVisibility = (providerID: string, checked: boolean) => {
|
| 41 |
+
providerList(providerID).forEach((x) => {
|
| 42 |
+
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
|
| 43 |
+
})
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<Dialog
|
| 48 |
+
title={language.t("dialog.model.manage")}
|
| 49 |
+
description={language.t("dialog.model.manage.description")}
|
| 50 |
+
action={
|
| 51 |
+
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={handleConnectProvider}>
|
| 52 |
+
{language.t("command.provider.connect")}
|
| 53 |
+
</Button>
|
| 54 |
+
}
|
| 55 |
+
>
|
| 56 |
+
<List
|
| 57 |
+
class="px-3"
|
| 58 |
+
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
|
| 59 |
+
emptyMessage={language.t("dialog.model.empty")}
|
| 60 |
+
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
| 61 |
+
items={local.model.list()}
|
| 62 |
+
filterKeys={["provider.name", "name", "id"]}
|
| 63 |
+
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
| 64 |
+
groupBy={(x) => x.provider.id}
|
| 65 |
+
groupHeader={(group) => {
|
| 66 |
+
const provider = group.items[0].provider
|
| 67 |
+
return (
|
| 68 |
+
<>
|
| 69 |
+
<span>{provider.name}</span>
|
| 70 |
+
<Tooltip
|
| 71 |
+
placement="top"
|
| 72 |
+
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
|
| 73 |
+
>
|
| 74 |
+
<Switch
|
| 75 |
+
class="-mr-1"
|
| 76 |
+
checked={providerVisible(provider.id)}
|
| 77 |
+
onChange={(checked) => setProviderVisibility(provider.id, checked)}
|
| 78 |
+
hideLabel
|
| 79 |
+
>
|
| 80 |
+
{provider.name}
|
| 81 |
+
</Switch>
|
| 82 |
+
</Tooltip>
|
| 83 |
+
</>
|
| 84 |
+
)
|
| 85 |
+
}}
|
| 86 |
+
sortGroupsBy={(a, b) => {
|
| 87 |
+
const aRank = providerRank(a.items[0].provider.id)
|
| 88 |
+
const bRank = providerRank(b.items[0].provider.id)
|
| 89 |
+
const aPopular = aRank >= 0
|
| 90 |
+
const bPopular = bRank >= 0
|
| 91 |
+
if (aPopular && !bPopular) return -1
|
| 92 |
+
if (!aPopular && bPopular) return 1
|
| 93 |
+
return aRank - bRank
|
| 94 |
+
}}
|
| 95 |
+
onSelect={(x) => {
|
| 96 |
+
if (!x) return
|
| 97 |
+
const key = { modelID: x.id, providerID: x.provider.id }
|
| 98 |
+
local.model.setVisibility(key, !local.model.visible(key))
|
| 99 |
+
}}
|
| 100 |
+
>
|
| 101 |
+
{(i) => (
|
| 102 |
+
<div class="w-full flex items-center justify-between gap-x-3">
|
| 103 |
+
<span>{i.name}</span>
|
| 104 |
+
<div onClick={(e) => e.stopPropagation()}>
|
| 105 |
+
<Switch
|
| 106 |
+
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
|
| 107 |
+
onChange={(checked) => {
|
| 108 |
+
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
| 109 |
+
}}
|
| 110 |
+
/>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
</List>
|
| 115 |
+
</Dialog>
|
| 116 |
+
)
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
export const DialogManageModelsV2: Component = () => {
|
| 120 |
+
const local = useLocal()
|
| 121 |
+
const language = useLanguage()
|
| 122 |
+
const dialog = useDialog()
|
| 123 |
+
const directory = () => decode64(local.slug())
|
| 124 |
+
|
| 125 |
+
const handleConnectProvider = () => {
|
| 126 |
+
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
| 127 |
+
}
|
| 128 |
+
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
| 129 |
+
const providerVisible = (providerID: string) =>
|
| 130 |
+
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
|
| 131 |
+
const setProviderVisibility = (providerID: string, checked: boolean) => {
|
| 132 |
+
providerList(providerID).forEach((x) => {
|
| 133 |
+
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
|
| 134 |
+
})
|
| 135 |
+
}
|
| 136 |
+
const setModelVisibility = (item: ModelItem, checked: boolean) => {
|
| 137 |
+
local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked)
|
| 138 |
+
}
|
| 139 |
+
const list = useFilteredList<ModelItem>({
|
| 140 |
+
items: () => local.model.list(),
|
| 141 |
+
key: (x) => `${x.provider.id}:${x.id}`,
|
| 142 |
+
filterKeys: ["provider.name", "name", "id"],
|
| 143 |
+
sortBy: (a, b) => a.name.localeCompare(b.name),
|
| 144 |
+
groupBy: (x) => x.provider.id,
|
| 145 |
+
sortGroupsBy: (a, b) => {
|
| 146 |
+
const aRank = popularProviders.indexOf(a.category)
|
| 147 |
+
const bRank = popularProviders.indexOf(b.category)
|
| 148 |
+
const aPopular = aRank >= 0
|
| 149 |
+
const bPopular = bRank >= 0
|
| 150 |
+
if (aPopular && !bPopular) return -1
|
| 151 |
+
if (!aPopular && bPopular) return 1
|
| 152 |
+
return aRank - bRank
|
| 153 |
+
},
|
| 154 |
+
})
|
| 155 |
+
|
| 156 |
+
return (
|
| 157 |
+
<DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
|
| 158 |
+
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
|
| 159 |
+
<DialogTitleGroup
|
| 160 |
+
title={language.t("dialog.model.manage")}
|
| 161 |
+
description={language.t("dialog.model.manage.description")}
|
| 162 |
+
/>
|
| 163 |
+
<ButtonV2 variant="neutral" icon="plus" onClick={handleConnectProvider}>
|
| 164 |
+
{language.t("command.provider.connect")}
|
| 165 |
+
</ButtonV2>
|
| 166 |
+
</DialogHeader>
|
| 167 |
+
<DialogBody class="flex min-h-0 flex-1 flex-col">
|
| 168 |
+
<div class="px-4 pt-px pb-3">
|
| 169 |
+
<div class="relative">
|
| 170 |
+
<TextInputV2
|
| 171 |
+
type="search"
|
| 172 |
+
appearance="base"
|
| 173 |
+
class="!w-full self-stretch"
|
| 174 |
+
value={list.filter()}
|
| 175 |
+
onInput={(event) => list.onInput(event.currentTarget.value)}
|
| 176 |
+
placeholder={language.t("dialog.model.search.placeholder")}
|
| 177 |
+
spellcheck={false}
|
| 178 |
+
autocorrect="off"
|
| 179 |
+
autocomplete="off"
|
| 180 |
+
autocapitalize="off"
|
| 181 |
+
autofocus
|
| 182 |
+
aria-label={language.t("dialog.model.search.placeholder")}
|
| 183 |
+
/>
|
| 184 |
+
<Show when={list.filter()}>
|
| 185 |
+
<IconButtonV2
|
| 186 |
+
type="button"
|
| 187 |
+
variant="ghost-muted"
|
| 188 |
+
size="small"
|
| 189 |
+
class="settings-v2-tab-search-clear"
|
| 190 |
+
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
| 191 |
+
onClick={() => list.clear()}
|
| 192 |
+
aria-label={language.t("common.clear")}
|
| 193 |
+
/>
|
| 194 |
+
</Show>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
|
| 198 |
+
<div class="settings-v2-panel settings-v2-models h-full px-4 pt-4 pb-4">
|
| 199 |
+
<Show
|
| 200 |
+
when={!list.grouped.loading}
|
| 201 |
+
fallback={
|
| 202 |
+
<div class="settings-v2-models-status">
|
| 203 |
+
{language.t("common.loading")}
|
| 204 |
+
{language.t("common.loading.ellipsis")}
|
| 205 |
+
</div>
|
| 206 |
+
}
|
| 207 |
+
>
|
| 208 |
+
<Show
|
| 209 |
+
when={list.flat().length > 0}
|
| 210 |
+
fallback={
|
| 211 |
+
<div class="settings-v2-models-status">
|
| 212 |
+
<span>{language.t("dialog.model.empty")}</span>
|
| 213 |
+
<Show when={list.filter()}>
|
| 214 |
+
<span class="settings-v2-models-status-filter">"{list.filter()}"</span>
|
| 215 |
+
</Show>
|
| 216 |
+
</div>
|
| 217 |
+
}
|
| 218 |
+
>
|
| 219 |
+
<For each={list.grouped.latest}>
|
| 220 |
+
{(group) => (
|
| 221 |
+
<div class="settings-v2-section" data-component="settings-models-provider">
|
| 222 |
+
<div class="settings-v2-models-group-header justify-between">
|
| 223 |
+
<div class="flex min-w-0 items-center gap-2">
|
| 224 |
+
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
|
| 225 |
+
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
| 226 |
+
</div>
|
| 227 |
+
<div>
|
| 228 |
+
<SwitchV2
|
| 229 |
+
class="mr-6"
|
| 230 |
+
checked={providerVisible(group.category)}
|
| 231 |
+
onChange={(checked) => setProviderVisibility(group.category, checked)}
|
| 232 |
+
hideLabel
|
| 233 |
+
>
|
| 234 |
+
{group.items[0].provider.name}
|
| 235 |
+
</SwitchV2>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
<SettingsListV2>
|
| 239 |
+
<For each={group.items}>
|
| 240 |
+
{(item) => (
|
| 241 |
+
<SettingsRowV2 title={item.name} description="">
|
| 242 |
+
<div>
|
| 243 |
+
<SwitchV2
|
| 244 |
+
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
| 245 |
+
onChange={(checked) => setModelVisibility(item, checked)}
|
| 246 |
+
hideLabel
|
| 247 |
+
>
|
| 248 |
+
{item.name}
|
| 249 |
+
</SwitchV2>
|
| 250 |
+
</div>
|
| 251 |
+
</SettingsRowV2>
|
| 252 |
+
)}
|
| 253 |
+
</For>
|
| 254 |
+
</SettingsListV2>
|
| 255 |
+
</div>
|
| 256 |
+
)}
|
| 257 |
+
</For>
|
| 258 |
+
</Show>
|
| 259 |
+
</Show>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
</DialogBody>
|
| 263 |
+
</DialogV2>
|
| 264 |
+
)
|
| 265 |
+
}
|
packages/app/src/components/dialog-release-notes.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createSignal } from "solid-js"
|
| 2 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 3 |
+
import { Button } from "@opencode-ai/ui/button"
|
| 4 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 5 |
+
import { useLanguage } from "@/context/language"
|
| 6 |
+
import { useSettings } from "@/context/settings"
|
| 7 |
+
|
| 8 |
+
export type Highlight = {
|
| 9 |
+
title: string
|
| 10 |
+
description: string
|
| 11 |
+
media?: {
|
| 12 |
+
type: "image" | "video"
|
| 13 |
+
src: string
|
| 14 |
+
alt?: string
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
| 19 |
+
const dialog = useDialog()
|
| 20 |
+
const language = useLanguage()
|
| 21 |
+
const settings = useSettings()
|
| 22 |
+
const [index, setIndex] = createSignal(0)
|
| 23 |
+
|
| 24 |
+
const total = () => props.highlights.length
|
| 25 |
+
const last = () => Math.max(0, total() - 1)
|
| 26 |
+
const feature = () => props.highlights[index()] ?? props.highlights[last()]
|
| 27 |
+
const isFirst = () => index() === 0
|
| 28 |
+
const isLast = () => index() >= last()
|
| 29 |
+
const paged = () => total() > 1
|
| 30 |
+
|
| 31 |
+
function handleNext() {
|
| 32 |
+
if (isLast()) return
|
| 33 |
+
setIndex(index() + 1)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function handleClose() {
|
| 37 |
+
dialog.close()
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function handleDisable() {
|
| 41 |
+
settings.general.setReleaseNotes(false)
|
| 42 |
+
handleClose()
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function handleKeyDown(e: KeyboardEvent) {
|
| 46 |
+
if (e.key === "Escape") {
|
| 47 |
+
e.preventDefault()
|
| 48 |
+
handleClose()
|
| 49 |
+
return
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (!paged()) return
|
| 53 |
+
if (e.key === "ArrowLeft" && !isFirst()) {
|
| 54 |
+
e.preventDefault()
|
| 55 |
+
setIndex(index() - 1)
|
| 56 |
+
}
|
| 57 |
+
if (e.key === "ArrowRight" && !isLast()) {
|
| 58 |
+
e.preventDefault()
|
| 59 |
+
setIndex(index() + 1)
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<Dialog
|
| 65 |
+
size="large"
|
| 66 |
+
fit
|
| 67 |
+
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
|
| 68 |
+
>
|
| 69 |
+
<div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
|
| 70 |
+
{/* Left side - Text content */}
|
| 71 |
+
<div class="flex flex-col flex-1 min-w-0 p-8">
|
| 72 |
+
{/* Top section - feature content (fixed position from top) */}
|
| 73 |
+
<div class="flex flex-col gap-2 pt-22">
|
| 74 |
+
<div class="flex items-center gap-2">
|
| 75 |
+
<h1 class="text-16-medium text-text-strong">{feature()?.title ?? ""}</h1>
|
| 76 |
+
</div>
|
| 77 |
+
<p class="text-14-regular text-text-base">{feature()?.description ?? ""}</p>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
{/* Spacer to push buttons to bottom */}
|
| 81 |
+
<div class="flex-1" />
|
| 82 |
+
|
| 83 |
+
{/* Bottom section - buttons and indicators (fixed position) */}
|
| 84 |
+
<div class="flex flex-col gap-12">
|
| 85 |
+
<div class="flex flex-col items-start gap-3">
|
| 86 |
+
{isLast() ? (
|
| 87 |
+
<Button variant="primary" size="large" onClick={handleClose}>
|
| 88 |
+
{language.t("dialog.releaseNotes.action.getStarted")}
|
| 89 |
+
</Button>
|
| 90 |
+
) : (
|
| 91 |
+
<Button variant="secondary" size="large" onClick={handleNext}>
|
| 92 |
+
{language.t("dialog.releaseNotes.action.next")}
|
| 93 |
+
</Button>
|
| 94 |
+
)}
|
| 95 |
+
|
| 96 |
+
<Button variant="ghost" size="small" onClick={handleDisable}>
|
| 97 |
+
{language.t("dialog.releaseNotes.action.hideFuture")}
|
| 98 |
+
</Button>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
{paged() && (
|
| 102 |
+
<div class="flex items-center gap-1.5 -my-2.5">
|
| 103 |
+
{props.highlights.map((_, i) => (
|
| 104 |
+
<button
|
| 105 |
+
type="button"
|
| 106 |
+
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
| 107 |
+
classList={{
|
| 108 |
+
"w-8": i === index(),
|
| 109 |
+
"w-3": i !== index(),
|
| 110 |
+
}}
|
| 111 |
+
onClick={() => setIndex(i)}
|
| 112 |
+
>
|
| 113 |
+
<div
|
| 114 |
+
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
| 115 |
+
classList={{
|
| 116 |
+
"bg-icon-strong-base": i === index(),
|
| 117 |
+
"bg-icon-weak-base": i !== index(),
|
| 118 |
+
}}
|
| 119 |
+
/>
|
| 120 |
+
</button>
|
| 121 |
+
))}
|
| 122 |
+
</div>
|
| 123 |
+
)}
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
{/* Right side - Media content (edge to edge) */}
|
| 128 |
+
{feature()?.media && (
|
| 129 |
+
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
| 130 |
+
{feature()!.media!.type === "image" ? (
|
| 131 |
+
<img
|
| 132 |
+
src={feature()!.media!.src}
|
| 133 |
+
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
| 134 |
+
class="w-full h-full object-cover"
|
| 135 |
+
/>
|
| 136 |
+
) : (
|
| 137 |
+
<video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
| 138 |
+
)}
|
| 139 |
+
</div>
|
| 140 |
+
)}
|
| 141 |
+
</div>
|
| 142 |
+
</Dialog>
|
| 143 |
+
)
|
| 144 |
+
}
|
packages/app/src/components/dialog-select-directory-v2.css
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.directory-picker-v2-body {
|
| 2 |
+
display: flex;
|
| 3 |
+
min-height: 0;
|
| 4 |
+
flex: 1;
|
| 5 |
+
flex-direction: column;
|
| 6 |
+
gap: 12px;
|
| 7 |
+
padding: 2px 16px 0;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.directory-picker-v2-path {
|
| 11 |
+
position: relative;
|
| 12 |
+
z-index: 10;
|
| 13 |
+
display: flex;
|
| 14 |
+
gap: 8px;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.directory-picker-v2-actions {
|
| 18 |
+
display: flex;
|
| 19 |
+
flex-shrink: 0;
|
| 20 |
+
gap: 2px;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.directory-picker-v2-suggestions {
|
| 24 |
+
position: absolute;
|
| 25 |
+
z-index: 20;
|
| 26 |
+
top: 36px;
|
| 27 |
+
right: 0;
|
| 28 |
+
left: 0;
|
| 29 |
+
display: flex;
|
| 30 |
+
flex-direction: column;
|
| 31 |
+
padding: 4px;
|
| 32 |
+
border: 1px solid var(--v2-border-border-base);
|
| 33 |
+
border-radius: 6px;
|
| 34 |
+
background: var(--v2-background-bg-layer-02);
|
| 35 |
+
box-shadow: var(--v2-elevation-overlay);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.directory-picker-v2-suggestions button {
|
| 39 |
+
overflow: hidden;
|
| 40 |
+
padding: 6px 8px;
|
| 41 |
+
border-radius: 4px;
|
| 42 |
+
color: var(--v2-text-text-muted);
|
| 43 |
+
font-size: 12px;
|
| 44 |
+
text-align: left;
|
| 45 |
+
text-overflow: ellipsis;
|
| 46 |
+
white-space: nowrap;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.directory-picker-v2-suggestions button:hover,
|
| 50 |
+
.directory-picker-v2-suggestions button[data-active] {
|
| 51 |
+
color: var(--v2-text-text-base);
|
| 52 |
+
background: var(--v2-overlay-simple-overlay-hover);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.directory-picker-v2-browser {
|
| 56 |
+
position: relative;
|
| 57 |
+
z-index: 0;
|
| 58 |
+
isolation: isolate;
|
| 59 |
+
min-height: 0;
|
| 60 |
+
flex: 1;
|
| 61 |
+
overflow: auto;
|
| 62 |
+
border: 1px solid var(--v2-border-border-base);
|
| 63 |
+
border-radius: 6px;
|
| 64 |
+
background: transparent;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.directory-picker-v2-tree {
|
| 68 |
+
display: block;
|
| 69 |
+
width: 100%;
|
| 70 |
+
height: 100%;
|
| 71 |
+
--trees-bg-override: transparent;
|
| 72 |
+
--trees-fg-override: var(--v2-text-text-base);
|
| 73 |
+
--trees-fg-muted-override: var(--v2-text-text-muted);
|
| 74 |
+
--trees-bg-muted-override: transparent;
|
| 75 |
+
--trees-selected-bg-override: transparent;
|
| 76 |
+
--trees-selected-fg-override: var(--v2-text-text-base);
|
| 77 |
+
--trees-selected-focused-border-color-override: transparent;
|
| 78 |
+
--trees-focus-ring-color-override: transparent;
|
| 79 |
+
--trees-focus-ring-width-override: 0px;
|
| 80 |
+
--trees-focus-ring-offset-override: 0px;
|
| 81 |
+
--trees-border-color-override: var(--v2-border-border-base);
|
| 82 |
+
--trees-font-family-override: var(--font-family-sans);
|
| 83 |
+
--trees-font-size-override: 12px;
|
| 84 |
+
--trees-item-height: 24px;
|
| 85 |
+
--trees-border-radius-override: 4px;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.directory-picker-v2-state {
|
| 89 |
+
position: absolute;
|
| 90 |
+
z-index: 1;
|
| 91 |
+
inset: 0;
|
| 92 |
+
display: grid;
|
| 93 |
+
place-items: center;
|
| 94 |
+
color: var(--v2-text-text-muted);
|
| 95 |
+
font-size: 12px;
|
| 96 |
+
pointer-events: none;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.directory-picker-v2-selection {
|
| 100 |
+
overflow: hidden;
|
| 101 |
+
flex-shrink: 0;
|
| 102 |
+
color: var(--v2-text-text-muted);
|
| 103 |
+
font-size: 12px;
|
| 104 |
+
line-height: 16px;
|
| 105 |
+
text-overflow: ellipsis;
|
| 106 |
+
white-space: nowrap;
|
| 107 |
+
}
|
packages/app/src/components/dialog-select-directory-v2.tsx
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "@pierre/trees/web-components"
|
| 2 |
+
import { FileTree } from "@pierre/trees"
|
| 3 |
+
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
| 4 |
+
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
| 5 |
+
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
| 6 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 7 |
+
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
| 8 |
+
import { useGlobal } from "@/context/global"
|
| 9 |
+
import { useLanguage } from "@/context/language"
|
| 10 |
+
import { ServerConnection } from "@/context/server"
|
| 11 |
+
import type { Path } from "@opencode-ai/sdk/v2/client"
|
| 12 |
+
import {
|
| 13 |
+
absoluteTreePath,
|
| 14 |
+
activeTreeNavigation,
|
| 15 |
+
advanceTreePreload,
|
| 16 |
+
nextSuggestionIndex,
|
| 17 |
+
nextTreeScrollTop,
|
| 18 |
+
pickerFileSearchQuery,
|
| 19 |
+
pickerAbsoluteInput,
|
| 20 |
+
pickerMode,
|
| 21 |
+
preloadTreeDirectories,
|
| 22 |
+
cleanPickerInput,
|
| 23 |
+
createPriorityTaskQueue,
|
| 24 |
+
createDirectorySearch,
|
| 25 |
+
currentPickerSuggestions,
|
| 26 |
+
displayPickerPath,
|
| 27 |
+
pickerParent,
|
| 28 |
+
pickerRoot,
|
| 29 |
+
} from "./directory-picker-domain"
|
| 30 |
+
import "./dialog-select-directory-v2.css"
|
| 31 |
+
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
| 32 |
+
import { getFilename } from "@opencode-ai/core/util/path"
|
| 33 |
+
|
| 34 |
+
interface DialogSelectDirectoryV2Props {
|
| 35 |
+
title?: string
|
| 36 |
+
multiple?: boolean
|
| 37 |
+
onSelect: (result: string | string[] | null) => void
|
| 38 |
+
server: ServerConnection.Any
|
| 39 |
+
mode?: "directory" | "file"
|
| 40 |
+
start?: string
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
| 44 |
+
const global = useGlobal()
|
| 45 |
+
const { sync, sdk } = global.ensureServerCtx(props.server)
|
| 46 |
+
const dialog = useDialog()
|
| 47 |
+
const language = useLanguage()
|
| 48 |
+
const policy = pickerMode(props.mode ?? "directory", props.start)
|
| 49 |
+
const action = {
|
| 50 |
+
file: language.t("dialog.directory.action.selectFile"),
|
| 51 |
+
directory: language.t("dialog.directory.action.selectFolder"),
|
| 52 |
+
}
|
| 53 |
+
const [root, setRoot] = createSignal("")
|
| 54 |
+
const [input, setInput] = createSignal("")
|
| 55 |
+
const [selected, setSelected] = createSignal("")
|
| 56 |
+
const [suggestionsOpen, setSuggestionsOpen] = createSignal(false)
|
| 57 |
+
const [activeSuggestion, setActiveSuggestion] = createSignal(-1)
|
| 58 |
+
const [loading, setLoading] = createSignal(false)
|
| 59 |
+
const [error, setError] = createSignal(false)
|
| 60 |
+
const [rootValid, setRootValid] = createSignal(false)
|
| 61 |
+
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
|
| 62 |
+
const loads = createPriorityTaskQueue<Array<{ name: string; type: "file" | "directory" }> | undefined>(3)
|
| 63 |
+
const advanced = new Set<string>()
|
| 64 |
+
let tree: FileTree | undefined
|
| 65 |
+
let container: HTMLDivElement | undefined
|
| 66 |
+
let pathArea: HTMLDivElement | undefined
|
| 67 |
+
let navigation = 0
|
| 68 |
+
|
| 69 |
+
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
| 70 |
+
const [fallbackPath] = createResource(
|
| 71 |
+
() => (missingBase() ? true : undefined),
|
| 72 |
+
async (): Promise<Path | undefined> => {
|
| 73 |
+
if ((await sdk.protocol) !== "v1") return
|
| 74 |
+
return sdk.client.path
|
| 75 |
+
.get()
|
| 76 |
+
.then((result) => result.data)
|
| 77 |
+
.catch(() => undefined)
|
| 78 |
+
},
|
| 79 |
+
{ initialValue: undefined },
|
| 80 |
+
)
|
| 81 |
+
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
| 82 |
+
const start = createMemo(
|
| 83 |
+
() =>
|
| 84 |
+
props.start ||
|
| 85 |
+
sync.data.path.home ||
|
| 86 |
+
sync.data.path.directory ||
|
| 87 |
+
fallbackPath()?.home ||
|
| 88 |
+
fallbackPath()?.directory,
|
| 89 |
+
)
|
| 90 |
+
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
| 91 |
+
const [suggestions] = createResource(input, async (value) => {
|
| 92 |
+
const cleaned = cleanPickerInput(value)
|
| 93 |
+
const typed = cleaned.replace(/\/+$/, "")
|
| 94 |
+
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
| 95 |
+
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
| 96 |
+
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
| 97 |
+
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
| 98 |
+
const base = pickerRoot(cleaned) || root() || start()
|
| 99 |
+
if (!base) return { query: value, items: directories.slice(0, 5) }
|
| 100 |
+
const files = await sdk.api.file
|
| 101 |
+
.find({
|
| 102 |
+
location: { directory: base },
|
| 103 |
+
query: pickerFileSearchQuery(base, value, home()),
|
| 104 |
+
type: "file",
|
| 105 |
+
limit: 20,
|
| 106 |
+
})
|
| 107 |
+
.then((result) => result.data)
|
| 108 |
+
.catch(() => [])
|
| 109 |
+
const results = [
|
| 110 |
+
...directories,
|
| 111 |
+
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
| 112 |
+
]
|
| 113 |
+
return {
|
| 114 |
+
query: value,
|
| 115 |
+
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
|
| 116 |
+
}
|
| 117 |
+
})
|
| 118 |
+
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
| 119 |
+
|
| 120 |
+
async function load(path: string, generation: number, eager = false) {
|
| 121 |
+
const key = path.replace(/\/+$/, "")
|
| 122 |
+
setError(false)
|
| 123 |
+
const absolute = absoluteTreePath(root(), key)
|
| 124 |
+
const existing = listings.get(key)
|
| 125 |
+
if (existing && !eager) loads.promote(`${generation}:${key}`)
|
| 126 |
+
const request =
|
| 127 |
+
existing ??
|
| 128 |
+
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
| 129 |
+
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
| 130 |
+
return sdk.api.file
|
| 131 |
+
.list({ location: { directory: absolute } })
|
| 132 |
+
.then((result) =>
|
| 133 |
+
result.data.map((entry) => ({
|
| 134 |
+
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
| 135 |
+
type: entry.type,
|
| 136 |
+
})),
|
| 137 |
+
)
|
| 138 |
+
.catch(() => undefined)
|
| 139 |
+
})
|
| 140 |
+
listings.set(key, request)
|
| 141 |
+
const nodes = await request
|
| 142 |
+
if (!activeTreeNavigation(generation, navigation)) return false
|
| 143 |
+
if (!nodes) {
|
| 144 |
+
listings.delete(key)
|
| 145 |
+
if (!key) setError(true)
|
| 146 |
+
return false
|
| 147 |
+
}
|
| 148 |
+
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
| 149 |
+
if (!eager && advanceTreePreload(advanced, key)) {
|
| 150 |
+
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
|
| 151 |
+
}
|
| 152 |
+
return true
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
async function navigate(path: string) {
|
| 156 |
+
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
|
| 157 |
+
if (!value) return
|
| 158 |
+
const token = ++navigation
|
| 159 |
+
setLoading(true)
|
| 160 |
+
setRootValid(false)
|
| 161 |
+
setSelected("")
|
| 162 |
+
setSuggestionsOpen(false)
|
| 163 |
+
setActiveSuggestion(-1)
|
| 164 |
+
setRoot(value)
|
| 165 |
+
setInput(displayPickerPath(value, value, home()))
|
| 166 |
+
listings.clear()
|
| 167 |
+
advanced.clear()
|
| 168 |
+
tree?.resetPaths([])
|
| 169 |
+
const valid = await load("", token)
|
| 170 |
+
if (!activeTreeNavigation(token, navigation)) return
|
| 171 |
+
setRootValid(valid)
|
| 172 |
+
setLoading(false)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function complete() {
|
| 176 |
+
const items = currentSuggestions()
|
| 177 |
+
const match = items[activeSuggestion()] ?? items[0]
|
| 178 |
+
if (!match) return
|
| 179 |
+
const value = displayPickerPath(match.absolute, input(), home())
|
| 180 |
+
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
|
| 181 |
+
if (match.type === "file") {
|
| 182 |
+
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
|
| 183 |
+
setSuggestionsOpen(false)
|
| 184 |
+
setActiveSuggestion(-1)
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
|
| 189 |
+
if (suggestion.type === "directory") {
|
| 190 |
+
void navigate(suggestion.absolute)
|
| 191 |
+
return
|
| 192 |
+
}
|
| 193 |
+
setInput(displayPickerPath(suggestion.absolute, input(), home()))
|
| 194 |
+
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
|
| 195 |
+
setSuggestionsOpen(false)
|
| 196 |
+
setActiveSuggestion(-1)
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function moveSuggestion(delta: -1 | 1) {
|
| 200 |
+
setSuggestionsOpen(true)
|
| 201 |
+
setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length))
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function activeSuggestionValue() {
|
| 205 |
+
const items = currentSuggestions()
|
| 206 |
+
return items[activeSuggestion()] ?? items[0]
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const keyActions: Partial<Record<string, () => void>> = {
|
| 210 |
+
ArrowDown: () => moveSuggestion(1),
|
| 211 |
+
ArrowUp: () => moveSuggestion(-1),
|
| 212 |
+
Enter: () => {
|
| 213 |
+
const suggestion = activeSuggestionValue()
|
| 214 |
+
if (suggestion) chooseSuggestion(suggestion)
|
| 215 |
+
if (!suggestion) void navigate(input())
|
| 216 |
+
},
|
| 217 |
+
Tab: complete,
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function handleInputKey(event: KeyboardEvent) {
|
| 221 |
+
const action = keyActions[event.key]
|
| 222 |
+
if (!action) return
|
| 223 |
+
if (event.key === "Tab" && event.shiftKey) return
|
| 224 |
+
event.preventDefault()
|
| 225 |
+
action()
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function resolve() {
|
| 229 |
+
const path = policy.result(root(), selected(), rootValid())
|
| 230 |
+
if (!path) return
|
| 231 |
+
props.onSelect(props.multiple ? [path] : path)
|
| 232 |
+
dialog.close()
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
onMount(() => {
|
| 236 |
+
const closeSuggestions = (event: PointerEvent) => {
|
| 237 |
+
if (pathArea?.contains(event.target as Node)) return
|
| 238 |
+
setSuggestionsOpen(false)
|
| 239 |
+
setActiveSuggestion(-1)
|
| 240 |
+
}
|
| 241 |
+
document.addEventListener("pointerdown", closeSuggestions)
|
| 242 |
+
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
|
| 243 |
+
tree = new FileTree({
|
| 244 |
+
paths: [],
|
| 245 |
+
flattenEmptyDirectories: false,
|
| 246 |
+
initialExpansion: "closed",
|
| 247 |
+
stickyFolders: true,
|
| 248 |
+
unsafeCSS: `
|
| 249 |
+
button[data-type="item"] {
|
| 250 |
+
background: transparent !important;
|
| 251 |
+
box-shadow: none !important;
|
| 252 |
+
}
|
| 253 |
+
button[data-type="item"]:hover {
|
| 254 |
+
background: var(--v2-overlay-simple-overlay-hover) !important;
|
| 255 |
+
}
|
| 256 |
+
button[data-type="item"]:focus-visible {
|
| 257 |
+
outline: none !important;
|
| 258 |
+
box-shadow: none !important;
|
| 259 |
+
}
|
| 260 |
+
[data-file-tree-virtualized-scroll] {
|
| 261 |
+
overscroll-behavior: contain;
|
| 262 |
+
scrollbar-width: thin;
|
| 263 |
+
}
|
| 264 |
+
`,
|
| 265 |
+
onExpansionChange(change) {
|
| 266 |
+
if (change.expanded) void load(change.path, navigation)
|
| 267 |
+
},
|
| 268 |
+
onSelectionChange(paths) {
|
| 269 |
+
const path = paths.at(-1)
|
| 270 |
+
setSelected(path ? (policy.selection(root(), path) ?? "") : "")
|
| 271 |
+
},
|
| 272 |
+
})
|
| 273 |
+
if (!container) return
|
| 274 |
+
tree.render({ containerWrapper: container })
|
| 275 |
+
tree.getFileTreeContainer()?.classList.add("directory-picker-v2-tree")
|
| 276 |
+
})
|
| 277 |
+
|
| 278 |
+
createEffect(() => {
|
| 279 |
+
const path = start()
|
| 280 |
+
if (!path || root()) return
|
| 281 |
+
void navigate(path)
|
| 282 |
+
})
|
| 283 |
+
|
| 284 |
+
onCleanup(() => tree?.cleanUp())
|
| 285 |
+
|
| 286 |
+
return (
|
| 287 |
+
<Dialog size="large" class="directory-picker-v2">
|
| 288 |
+
<DialogHeader>
|
| 289 |
+
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
|
| 290 |
+
</DialogHeader>
|
| 291 |
+
<DividerV2 />
|
| 292 |
+
<DialogBody class="directory-picker-v2-body pt-4!">
|
| 293 |
+
<div class="directory-picker-v2-path" ref={pathArea}>
|
| 294 |
+
<TextInputV2
|
| 295 |
+
value={input()}
|
| 296 |
+
autofocus
|
| 297 |
+
autocomplete="off"
|
| 298 |
+
spellcheck={false}
|
| 299 |
+
class="!w-full"
|
| 300 |
+
onInput={(event) => {
|
| 301 |
+
setInput(cleanPickerInput(event.currentTarget.value))
|
| 302 |
+
setSelected("")
|
| 303 |
+
setSuggestionsOpen(true)
|
| 304 |
+
setActiveSuggestion(-1)
|
| 305 |
+
}}
|
| 306 |
+
role="combobox"
|
| 307 |
+
aria-autocomplete="list"
|
| 308 |
+
aria-expanded={suggestionsOpen()}
|
| 309 |
+
aria-controls="directory-picker-v2-suggestions"
|
| 310 |
+
aria-activedescendant={
|
| 311 |
+
activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined
|
| 312 |
+
}
|
| 313 |
+
onKeyDown={handleInputKey}
|
| 314 |
+
/>
|
| 315 |
+
<div class="directory-picker-v2-actions">
|
| 316 |
+
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(home())}>
|
| 317 |
+
~
|
| 318 |
+
</ButtonV2>
|
| 319 |
+
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>
|
| 320 |
+
{language.t("dialog.directory.root")}
|
| 321 |
+
</ButtonV2>
|
| 322 |
+
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>
|
| 323 |
+
{language.t("dialog.directory.parent")}
|
| 324 |
+
</ButtonV2>
|
| 325 |
+
</div>
|
| 326 |
+
<Show when={suggestionsOpen() && currentSuggestions().length > 0}>
|
| 327 |
+
<div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions">
|
| 328 |
+
<For each={currentSuggestions()}>
|
| 329 |
+
{(suggestion, index) => (
|
| 330 |
+
<button
|
| 331 |
+
id={`directory-picker-v2-suggestion-${index()}`}
|
| 332 |
+
data-directory-path={suggestion.absolute}
|
| 333 |
+
role="option"
|
| 334 |
+
aria-selected={index() === activeSuggestion()}
|
| 335 |
+
data-active={index() === activeSuggestion() ? "" : undefined}
|
| 336 |
+
onPointerMove={() => setActiveSuggestion(index())}
|
| 337 |
+
onClick={() => chooseSuggestion(suggestion)}
|
| 338 |
+
>
|
| 339 |
+
{displayPickerPath(suggestion.absolute, input(), home())}
|
| 340 |
+
{suggestion.type === "directory" ? "/" : ""}
|
| 341 |
+
</button>
|
| 342 |
+
)}
|
| 343 |
+
</For>
|
| 344 |
+
</div>
|
| 345 |
+
</Show>
|
| 346 |
+
</div>
|
| 347 |
+
<div
|
| 348 |
+
class="directory-picker-v2-browser"
|
| 349 |
+
ref={container}
|
| 350 |
+
onWheel={(event) => {
|
| 351 |
+
const scroller = tree
|
| 352 |
+
?.getFileTreeContainer()
|
| 353 |
+
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
|
| 354 |
+
if (!scroller) return
|
| 355 |
+
const next = nextTreeScrollTop(
|
| 356 |
+
scroller.scrollTop,
|
| 357 |
+
event.deltaY,
|
| 358 |
+
scroller.scrollHeight,
|
| 359 |
+
scroller.clientHeight,
|
| 360 |
+
)
|
| 361 |
+
if (next === scroller.scrollTop) return
|
| 362 |
+
event.preventDefault()
|
| 363 |
+
scroller.scrollTop = next
|
| 364 |
+
scroller.dispatchEvent(new Event("scroll"))
|
| 365 |
+
}}
|
| 366 |
+
>
|
| 367 |
+
<Show when={loading()}>
|
| 368 |
+
<div class="directory-picker-v2-state">{language.t("common.loading")}</div>
|
| 369 |
+
</Show>
|
| 370 |
+
<Show when={!loading() && error()}>
|
| 371 |
+
<div class="directory-picker-v2-state">{language.t("dialog.directory.readError")}</div>
|
| 372 |
+
</Show>
|
| 373 |
+
</div>
|
| 374 |
+
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
|
| 375 |
+
</DialogBody>
|
| 376 |
+
<DialogFooter>
|
| 377 |
+
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
| 378 |
+
{language.t("common.cancel")}
|
| 379 |
+
</ButtonV2>
|
| 380 |
+
<ButtonV2 variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
|
| 381 |
+
{action[policy.action]}
|
| 382 |
+
</ButtonV2>
|
| 383 |
+
</DialogFooter>
|
| 384 |
+
</Dialog>
|
| 385 |
+
)
|
| 386 |
+
}
|
packages/app/src/components/dialog-select-directory.tsx
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 type { ListRef } from "@opencode-ai/ui/list"
|
| 6 |
+
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
| 7 |
+
import { createMemo, createResource, createSignal } from "solid-js"
|
| 8 |
+
import { useLanguage } from "@/context/language"
|
| 9 |
+
import { ServerConnection } from "@/context/server"
|
| 10 |
+
import { useGlobal } from "@/context/global"
|
| 11 |
+
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
| 12 |
+
import type { Path } from "@opencode-ai/sdk/v2/client"
|
| 13 |
+
|
| 14 |
+
interface DialogSelectDirectoryProps {
|
| 15 |
+
title?: string
|
| 16 |
+
multiple?: boolean
|
| 17 |
+
onSelect: (result: string | string[] | null) => void
|
| 18 |
+
server: ServerConnection.Any
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const RECENT_PROJECT_LIMIT = 5
|
| 22 |
+
|
| 23 |
+
type Row = {
|
| 24 |
+
absolute: string
|
| 25 |
+
search: string
|
| 26 |
+
group: "recent" | "folders"
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function toRow(absolute: string, home: string, group: Row["group"]): Row {
|
| 30 |
+
const full = displayPickerPath(absolute, "", "")
|
| 31 |
+
const tilde = displayPickerPath(full, "~", home)
|
| 32 |
+
const withSlash = (value: string) => {
|
| 33 |
+
if (!value) return ""
|
| 34 |
+
if (value.endsWith("/")) return value
|
| 35 |
+
return value + "/"
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const search = Array.from(
|
| 39 |
+
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
| 40 |
+
).join("\n")
|
| 41 |
+
return { absolute: full, search, group }
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function uniqueRows(rows: Row[]) {
|
| 45 |
+
const seen = new Set<string>()
|
| 46 |
+
return rows.filter((row) => {
|
| 47 |
+
if (seen.has(row.absolute)) return false
|
| 48 |
+
seen.add(row.absolute)
|
| 49 |
+
return true
|
| 50 |
+
})
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
| 54 |
+
const global = useGlobal()
|
| 55 |
+
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
|
| 56 |
+
const dialog = useDialog()
|
| 57 |
+
const language = useLanguage()
|
| 58 |
+
|
| 59 |
+
const [filter, setFilter] = createSignal("")
|
| 60 |
+
let list: ListRef | undefined
|
| 61 |
+
|
| 62 |
+
const missingHome = createMemo(() => !sync.data.path.home)
|
| 63 |
+
const [fallbackPath] = createResource(
|
| 64 |
+
() => (missingHome() ? true : undefined),
|
| 65 |
+
async (): Promise<Path | undefined> => {
|
| 66 |
+
if ((await sdk.protocol) !== "v1") return
|
| 67 |
+
return sdk.client.path
|
| 68 |
+
.get()
|
| 69 |
+
.then((result) => result.data)
|
| 70 |
+
.catch(() => undefined)
|
| 71 |
+
},
|
| 72 |
+
{ initialValue: undefined },
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
| 76 |
+
const start = createMemo(
|
| 77 |
+
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
const directories = createDirectorySearch({
|
| 81 |
+
sdk,
|
| 82 |
+
home,
|
| 83 |
+
base: start,
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
const recentProjects = createMemo(() => {
|
| 87 |
+
const projects = serverCtx.projects.list()
|
| 88 |
+
const byProject = new Map<string, number>()
|
| 89 |
+
|
| 90 |
+
for (const project of projects) {
|
| 91 |
+
let at = 0
|
| 92 |
+
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
| 93 |
+
for (const directory of dirs) {
|
| 94 |
+
const sessions = sync.child(directory, { bootstrap: false })[0].session
|
| 95 |
+
for (const session of sessions) {
|
| 96 |
+
if (session.time.archived) continue
|
| 97 |
+
const updated = session.time.updated ?? session.time.created
|
| 98 |
+
if (updated > at) at = updated
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
byProject.set(project.worktree, at)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
return projects
|
| 105 |
+
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
| 106 |
+
.sort((a, b) => b.at - a.at || a.index - b.index)
|
| 107 |
+
.map(({ project }) => {
|
| 108 |
+
const row = toRow(project.worktree, home(), "recent")
|
| 109 |
+
const name = project.name || getFilename(project.worktree)
|
| 110 |
+
return {
|
| 111 |
+
...row,
|
| 112 |
+
search: `${row.search}\n${name}`,
|
| 113 |
+
}
|
| 114 |
+
})
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
const items = async (value: string) => {
|
| 118 |
+
const results = await directories(value)
|
| 119 |
+
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
| 120 |
+
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
|
| 121 |
+
const recent = recentProjects()
|
| 122 |
+
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
|
| 123 |
+
return uniqueRows([...visible, ...directoryRows])
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function resolve(absolute: string) {
|
| 127 |
+
props.onSelect(props.multiple ? [absolute] : absolute)
|
| 128 |
+
dialog.close()
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
return (
|
| 132 |
+
<Dialog title={props.title ?? language.t("command.project.open")}>
|
| 133 |
+
<List
|
| 134 |
+
class="px-3"
|
| 135 |
+
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
|
| 136 |
+
emptyMessage={language.t("dialog.directory.empty")}
|
| 137 |
+
loadingMessage={language.t("common.loading")}
|
| 138 |
+
items={items}
|
| 139 |
+
key={(x) => x.absolute}
|
| 140 |
+
filterKeys={["search"]}
|
| 141 |
+
groupBy={(item) => item.group}
|
| 142 |
+
sortGroupsBy={(a, b) => {
|
| 143 |
+
if (a.category === b.category) return 0
|
| 144 |
+
return a.category === "recent" ? -1 : 1
|
| 145 |
+
}}
|
| 146 |
+
groupHeader={(group) =>
|
| 147 |
+
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
|
| 148 |
+
}
|
| 149 |
+
ref={(r) => (list = r)}
|
| 150 |
+
onFilter={(value) => setFilter(cleanPickerInput(value))}
|
| 151 |
+
onKeyEvent={(e, item) => {
|
| 152 |
+
if (e.key !== "Tab") return
|
| 153 |
+
if (e.shiftKey) return
|
| 154 |
+
if (!item) return
|
| 155 |
+
|
| 156 |
+
e.preventDefault()
|
| 157 |
+
e.stopPropagation()
|
| 158 |
+
|
| 159 |
+
const value = displayPickerPath(item.absolute, filter(), home())
|
| 160 |
+
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
| 161 |
+
}}
|
| 162 |
+
onSelect={(path) => {
|
| 163 |
+
if (!path) return
|
| 164 |
+
resolve(path.absolute)
|
| 165 |
+
}}
|
| 166 |
+
>
|
| 167 |
+
{(item) => {
|
| 168 |
+
const path = displayPickerPath(item.absolute, filter(), home())
|
| 169 |
+
if (path === "~") {
|
| 170 |
+
return (
|
| 171 |
+
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
| 172 |
+
<div class="flex items-center gap-x-3 grow min-w-0">
|
| 173 |
+
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
| 174 |
+
<div class="flex items-center text-14-regular min-w-0">
|
| 175 |
+
<span class="text-text-strong whitespace-nowrap">~</span>
|
| 176 |
+
<span class="text-text-weak whitespace-nowrap">/</span>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
)
|
| 181 |
+
}
|
| 182 |
+
return (
|
| 183 |
+
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
| 184 |
+
<div class="flex items-center gap-x-3 grow min-w-0">
|
| 185 |
+
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
| 186 |
+
<div class="flex items-center text-14-regular min-w-0">
|
| 187 |
+
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
| 188 |
+
{getDirectory(path)}
|
| 189 |
+
</span>
|
| 190 |
+
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
|
| 191 |
+
<span class="text-text-weak whitespace-nowrap">/</span>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
)
|
| 196 |
+
}}
|
| 197 |
+
</List>
|
| 198 |
+
</Dialog>
|
| 199 |
+
)
|
| 200 |
+
}
|
packages/app/src/components/dialog-select-file.tsx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 2 |
+
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
| 3 |
+
import { Icon } from "@opencode-ai/ui/icon"
|
| 4 |
+
import { Keybind } from "@opencode-ai/ui/keybind"
|
| 5 |
+
import { List } from "@opencode-ai/ui/list"
|
| 6 |
+
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
| 7 |
+
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
|
| 8 |
+
import { formatKeybind } from "@/context/command"
|
| 9 |
+
import { useServerSDK } from "@/context/server-sdk"
|
| 10 |
+
import { useLanguage } from "@/context/language"
|
| 11 |
+
import { usePlatform } from "@/context/platform"
|
| 12 |
+
import { useSettings } from "@/context/settings"
|
| 13 |
+
import { useSessionLayout } from "@/pages/session/session-layout"
|
| 14 |
+
import { decode64 } from "@/utils/base64"
|
| 15 |
+
import { getRelativeTime } from "@/utils/time"
|
| 16 |
+
import {
|
| 17 |
+
createCommandPaletteFileEntry,
|
| 18 |
+
createCommandPaletteFileOpener,
|
| 19 |
+
createCommandPaletteModel,
|
| 20 |
+
uniqueCommandPaletteEntries,
|
| 21 |
+
type CommandPaletteEntry,
|
| 22 |
+
} from "./command-palette"
|
| 23 |
+
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
|
| 24 |
+
|
| 25 |
+
const DialogSelectFileV2 = lazy(() =>
|
| 26 |
+
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
| 27 |
+
)
|
| 28 |
+
type DialogSelectFileMode = "all" | "files"
|
| 29 |
+
|
| 30 |
+
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
| 31 |
+
const platform = usePlatform()
|
| 32 |
+
const settings = useSettings()
|
| 33 |
+
const filesOnly = () => props.mode === "files"
|
| 34 |
+
|
| 35 |
+
if (!filesOnly() && settings.general.newLayoutDesigns()) {
|
| 36 |
+
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
| 40 |
+
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
|
| 47 |
+
const language = useLanguage()
|
| 48 |
+
const serverSDK = useServerSDK()
|
| 49 |
+
const { params } = useSessionLayout()
|
| 50 |
+
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
| 51 |
+
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<DialogSelectFileV2
|
| 55 |
+
server={serverSDK().server}
|
| 56 |
+
mode="file"
|
| 57 |
+
start={projectDirectory()}
|
| 58 |
+
title={language.t("session.header.searchFiles")}
|
| 59 |
+
onSelect={(result) => {
|
| 60 |
+
if (typeof result !== "string") return
|
| 61 |
+
openFile(result)
|
| 62 |
+
}}
|
| 63 |
+
/>
|
| 64 |
+
)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
|
| 68 |
+
const palette = createCommandPaletteModel(props)
|
| 69 |
+
const [grouped, setGrouped] = createSignal(false)
|
| 70 |
+
|
| 71 |
+
const items = async (text: string) => {
|
| 72 |
+
const query = text.trim()
|
| 73 |
+
setGrouped(query.length > 0)
|
| 74 |
+
|
| 75 |
+
if (!query && props.filesOnly()) {
|
| 76 |
+
const loaded = palette.file.tree.state("")?.loaded
|
| 77 |
+
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
|
| 78 |
+
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
|
| 79 |
+
|
| 80 |
+
if (loaded || next.length > 0) {
|
| 81 |
+
void pending
|
| 82 |
+
return next
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
await pending
|
| 86 |
+
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
| 90 |
+
|
| 91 |
+
if (props.filesOnly()) {
|
| 92 |
+
const files = await palette.file.searchFiles(query)
|
| 93 |
+
const category = palette.language.t("palette.group.files")
|
| 94 |
+
return files.map((path) => createCommandPaletteFileEntry(path, category))
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const [files, nextSessions] = await Promise.all([
|
| 98 |
+
palette.file.searchFiles(query),
|
| 99 |
+
Promise.resolve(palette.sessions(query)),
|
| 100 |
+
])
|
| 101 |
+
const category = palette.language.t("palette.group.files")
|
| 102 |
+
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
|
| 103 |
+
return [...palette.commandEntries(), ...nextSessions, ...entries]
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
return (
|
| 107 |
+
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
|
| 108 |
+
<List
|
| 109 |
+
class="px-3"
|
| 110 |
+
search={{
|
| 111 |
+
placeholder: props.filesOnly()
|
| 112 |
+
? palette.language.t("session.header.searchFiles")
|
| 113 |
+
: palette.language.t("palette.search.placeholder"),
|
| 114 |
+
autofocus: true,
|
| 115 |
+
hideIcon: true,
|
| 116 |
+
}}
|
| 117 |
+
emptyMessage={palette.language.t("palette.empty")}
|
| 118 |
+
loadingMessage={palette.language.t("common.loading")}
|
| 119 |
+
items={items}
|
| 120 |
+
key={(item) => item.id}
|
| 121 |
+
filterKeys={["title", "description", "category"]}
|
| 122 |
+
skipFilter={(item) => item.type === "file"}
|
| 123 |
+
groupBy={grouped() ? (item) => item.category : () => ""}
|
| 124 |
+
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
|
| 125 |
+
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
|
| 126 |
+
>
|
| 127 |
+
{(item) => (
|
| 128 |
+
<Switch
|
| 129 |
+
fallback={
|
| 130 |
+
<div class="w-full flex items-center justify-between rounded-md pl-1">
|
| 131 |
+
<div class="flex items-center gap-x-3 grow min-w-0">
|
| 132 |
+
<FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
|
| 133 |
+
<div class="flex items-center text-14-regular">
|
| 134 |
+
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
| 135 |
+
{getDirectory(item.path ?? "")}
|
| 136 |
+
</span>
|
| 137 |
+
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path ?? "")}</span>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
}
|
| 142 |
+
>
|
| 143 |
+
<Match when={item.type === "command"}>
|
| 144 |
+
<div class="w-full flex items-center justify-between gap-4">
|
| 145 |
+
<div class="flex items-center gap-2 min-w-0">
|
| 146 |
+
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
|
| 147 |
+
<Show when={item.description}>
|
| 148 |
+
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
|
| 149 |
+
</Show>
|
| 150 |
+
</div>
|
| 151 |
+
<Show when={item.keybind}>
|
| 152 |
+
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
|
| 153 |
+
</Show>
|
| 154 |
+
</div>
|
| 155 |
+
</Match>
|
| 156 |
+
<Match when={item.type === "session"}>
|
| 157 |
+
<div class="w-full flex items-center justify-between rounded-md pl-1">
|
| 158 |
+
<div class="flex items-center gap-x-3 grow min-w-0">
|
| 159 |
+
<Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
|
| 160 |
+
<div class="flex items-center gap-2 min-w-0">
|
| 161 |
+
<span
|
| 162 |
+
class="text-14-regular text-text-strong truncate"
|
| 163 |
+
classList={{ "opacity-70": !!item.archived }}
|
| 164 |
+
>
|
| 165 |
+
{item.title}
|
| 166 |
+
</span>
|
| 167 |
+
<Show when={item.description}>
|
| 168 |
+
<span
|
| 169 |
+
class="text-14-regular text-text-weak truncate"
|
| 170 |
+
classList={{ "opacity-70": !!item.archived }}
|
| 171 |
+
>
|
| 172 |
+
{item.description}
|
| 173 |
+
</span>
|
| 174 |
+
</Show>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
<Show when={item.updated}>
|
| 178 |
+
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
| 179 |
+
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
|
| 180 |
+
</span>
|
| 181 |
+
</Show>
|
| 182 |
+
</div>
|
| 183 |
+
</Match>
|
| 184 |
+
</Switch>
|
| 185 |
+
)}
|
| 186 |
+
</List>
|
| 187 |
+
</Dialog>
|
| 188 |
+
)
|
| 189 |
+
}
|
packages/app/src/components/dialog-select-mcp.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Component, createMemo, Show } from "solid-js"
|
| 2 |
+
import { useSync } from "@/context/sync"
|
| 3 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 4 |
+
import { List } from "@opencode-ai/ui/list"
|
| 5 |
+
import { Switch } from "@opencode-ai/ui/switch"
|
| 6 |
+
import { useLanguage } from "@/context/language"
|
| 7 |
+
import { useMcpToggle } from "@/context/mcp"
|
| 8 |
+
|
| 9 |
+
const statusLabels = {
|
| 10 |
+
connected: "mcp.status.connected",
|
| 11 |
+
failed: "mcp.status.failed",
|
| 12 |
+
needs_auth: "mcp.status.needs_auth",
|
| 13 |
+
needs_client_registration: "mcp.status.needs_client_registration",
|
| 14 |
+
disabled: "mcp.status.disabled",
|
| 15 |
+
} as const
|
| 16 |
+
|
| 17 |
+
export const DialogSelectMcp: Component = () => {
|
| 18 |
+
const sync = useSync()
|
| 19 |
+
const language = useLanguage()
|
| 20 |
+
|
| 21 |
+
const items = createMemo(() =>
|
| 22 |
+
Object.entries(sync().data.mcp ?? {})
|
| 23 |
+
.map(([name, status]) => ({ name, status: status.status }))
|
| 24 |
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
const toggle = useMcpToggle()
|
| 28 |
+
|
| 29 |
+
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
| 30 |
+
const totalCount = createMemo(() => items().length)
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<Dialog
|
| 34 |
+
title={language.t("dialog.mcp.title")}
|
| 35 |
+
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
|
| 36 |
+
>
|
| 37 |
+
<List
|
| 38 |
+
class="px-3"
|
| 39 |
+
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
|
| 40 |
+
emptyMessage={language.t("dialog.mcp.empty")}
|
| 41 |
+
key={(x) => x?.name ?? ""}
|
| 42 |
+
items={items}
|
| 43 |
+
filterKeys={["name", "status"]}
|
| 44 |
+
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
| 45 |
+
onSelect={(x) => {
|
| 46 |
+
if (!x || x.status === "pending" || toggle.isPending) return
|
| 47 |
+
toggle.mutate(x.name)
|
| 48 |
+
}}
|
| 49 |
+
>
|
| 50 |
+
{(i) => {
|
| 51 |
+
const mcpStatus = () => sync().data.mcp[i.name]
|
| 52 |
+
const status = () => mcpStatus()?.status
|
| 53 |
+
const statusLabel = () => {
|
| 54 |
+
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
| 55 |
+
if (!key) return
|
| 56 |
+
return language.t(key)
|
| 57 |
+
}
|
| 58 |
+
const error = () => {
|
| 59 |
+
const s = mcpStatus()
|
| 60 |
+
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
| 61 |
+
}
|
| 62 |
+
const enabled = () => status() === "connected"
|
| 63 |
+
return (
|
| 64 |
+
<div class="w-full flex items-center justify-between gap-x-3">
|
| 65 |
+
<div class="flex flex-col gap-0.5 min-w-0">
|
| 66 |
+
<div class="flex items-center gap-2">
|
| 67 |
+
<span class="truncate">{i.name}</span>
|
| 68 |
+
<Show when={statusLabel()}>
|
| 69 |
+
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
| 70 |
+
</Show>
|
| 71 |
+
</div>
|
| 72 |
+
<Show when={error()}>
|
| 73 |
+
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
|
| 74 |
+
</Show>
|
| 75 |
+
</div>
|
| 76 |
+
<div onClick={(e) => e.stopPropagation()}>
|
| 77 |
+
<Switch
|
| 78 |
+
checked={enabled()}
|
| 79 |
+
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
|
| 80 |
+
onChange={() => {
|
| 81 |
+
if (toggle.isPending) return
|
| 82 |
+
toggle.mutate(i.name)
|
| 83 |
+
}}
|
| 84 |
+
/>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
)
|
| 88 |
+
}}
|
| 89 |
+
</List>
|
| 90 |
+
</Dialog>
|
| 91 |
+
)
|
| 92 |
+
}
|
packages/app/src/components/dialog-select-model-search.test.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, test } from "bun:test"
|
| 2 |
+
import { matchesModelSearch } from "./dialog-select-model-search"
|
| 3 |
+
|
| 4 |
+
describe("matchesModelSearch", () => {
|
| 5 |
+
test("matches model names across separators", () => {
|
| 6 |
+
expect(matchesModelSearch("gpt 5", ["GPT-5.5"])).toBe(true)
|
| 7 |
+
expect(matchesModelSearch("gpt-5", ["GPT-5.5"])).toBe(true)
|
| 8 |
+
expect(matchesModelSearch("gpt5", ["GPT-5.5"])).toBe(true)
|
| 9 |
+
})
|
| 10 |
+
|
| 11 |
+
test("matches any searchable model field", () => {
|
| 12 |
+
expect(matchesModelSearch("open ai", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
|
| 13 |
+
expect(matchesModelSearch("gpt 5", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
|
| 14 |
+
})
|
| 15 |
+
|
| 16 |
+
test("does not match unrelated searches", () => {
|
| 17 |
+
expect(matchesModelSearch("claude", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(false)
|
| 18 |
+
})
|
| 19 |
+
})
|
packages/app/src/components/dialog-select-model-search.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const normalizeModelSearch = (value: string) =>
|
| 2 |
+
value
|
| 3 |
+
.toLowerCase()
|
| 4 |
+
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
|
| 5 |
+
.trim()
|
| 6 |
+
.replace(/\s+/g, " ")
|
| 7 |
+
|
| 8 |
+
export const compactModelSearch = (value: string) => normalizeModelSearch(value).replaceAll(" ", "")
|
| 9 |
+
|
| 10 |
+
export const matchesModelSearch = (query: string, values: string[]) => {
|
| 11 |
+
const search = normalizeModelSearch(query)
|
| 12 |
+
if (!search) return true
|
| 13 |
+
|
| 14 |
+
const compactSearch = compactModelSearch(query)
|
| 15 |
+
return values.some(
|
| 16 |
+
(value) => normalizeModelSearch(value).includes(search) || compactModelSearch(value).includes(compactSearch),
|
| 17 |
+
)
|
| 18 |
+
}
|
packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @ts-nocheck
|
| 2 |
+
import { Button } from "@opencode-ai/ui/button"
|
| 3 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 4 |
+
import { createSignal, onMount } from "solid-js"
|
| 5 |
+
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
|
| 6 |
+
|
| 7 |
+
const names = [
|
| 8 |
+
"MiMo V2.5 Free",
|
| 9 |
+
"Nemotron 3 Ultra Free",
|
| 10 |
+
"Deepseek V4 Flash Free",
|
| 11 |
+
"North Mini Code Free",
|
| 12 |
+
"Hy3 Free",
|
| 13 |
+
"Big Pickle",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
function SelectModelWithoutProviders() {
|
| 17 |
+
const dialog = useDialog()
|
| 18 |
+
const models = names.map((name, index) => ({
|
| 19 |
+
id: name.toLowerCase().replaceAll(" ", "-"),
|
| 20 |
+
name,
|
| 21 |
+
provider: { id: "opencode", name: "OpenCode" },
|
| 22 |
+
cost: { input: 0, output: 0 },
|
| 23 |
+
limit: { context: 128_000 },
|
| 24 |
+
capabilities: {
|
| 25 |
+
reasoning: index !== 5,
|
| 26 |
+
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
| 27 |
+
},
|
| 28 |
+
}))
|
| 29 |
+
const [current, setCurrent] = createSignal(models[2])
|
| 30 |
+
const model = {
|
| 31 |
+
list: () => models,
|
| 32 |
+
current,
|
| 33 |
+
set(value) {
|
| 34 |
+
setCurrent(models.find((item) => item.id === value?.modelID))
|
| 35 |
+
},
|
| 36 |
+
}
|
| 37 |
+
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
|
| 38 |
+
|
| 39 |
+
onMount(open)
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<Button variant="secondary" onClick={open}>
|
| 43 |
+
Open select model dialog
|
| 44 |
+
</Button>
|
| 45 |
+
)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export default {
|
| 49 |
+
title: "App/Dialogs/Select Model",
|
| 50 |
+
id: "app-dialog-select-model",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export const WithoutProviders = {
|
| 54 |
+
render: () => <SelectModelWithoutProviders />,
|
| 55 |
+
}
|
packages/app/src/components/dialog-select-model-unpaid-v2.tsx
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
| 2 |
+
import { Icon } from "@opencode-ai/ui/v2/icon"
|
| 3 |
+
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
| 4 |
+
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
| 5 |
+
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
| 6 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 7 |
+
import { useTheme } from "@opencode-ai/ui/theme"
|
| 8 |
+
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
| 9 |
+
import { useLocal } from "@/context/local"
|
| 10 |
+
import { useProviders } from "@/hooks/use-providers"
|
| 11 |
+
import { decode64 } from "@/utils/base64"
|
| 12 |
+
import { useLanguage } from "@/context/language"
|
| 13 |
+
import { ModelTooltip } from "./model-tooltip"
|
| 14 |
+
|
| 15 |
+
type ModelState = ReturnType<typeof useLocal>["model"]
|
| 16 |
+
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
|
| 17 |
+
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
| 18 |
+
|
| 19 |
+
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
| 20 |
+
const local = useLocal()
|
| 21 |
+
const model = props.model ?? local.model
|
| 22 |
+
const dialog = useDialog()
|
| 23 |
+
const theme = useTheme()
|
| 24 |
+
const directory = () => decode64(local.slug())
|
| 25 |
+
const providers = useProviders(directory)
|
| 26 |
+
const language = useLanguage()
|
| 27 |
+
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
|
| 28 |
+
const currentKey = createMemo(() => {
|
| 29 |
+
const c = model.current()
|
| 30 |
+
return c ? `${c.provider.id}:${c.id}` : undefined
|
| 31 |
+
})
|
| 32 |
+
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
| 33 |
+
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
| 34 |
+
const freeModels = createMemo(() => model.list().filter(isFree))
|
| 35 |
+
|
| 36 |
+
const openProviders = (provider?: string) => {
|
| 37 |
+
void import("./dialog-connect-provider").then((x) => {
|
| 38 |
+
const controller = x.useProviderConnectController()
|
| 39 |
+
controller.select(provider)
|
| 40 |
+
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
| 41 |
+
})
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
|
| 45 |
+
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
| 46 |
+
dialog.close()
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Focus starts on the dialog's close button, outside the list, so listen at the
|
| 50 |
+
// document level while the dialog is mounted instead of on the list container.
|
| 51 |
+
let listEl: HTMLDivElement | undefined
|
| 52 |
+
onMount(() => {
|
| 53 |
+
const handleKeyDown = (e: KeyboardEvent) => {
|
| 54 |
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
|
| 55 |
+
if (!listEl) return
|
| 56 |
+
const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
|
| 57 |
+
if (buttons.length === 0) return
|
| 58 |
+
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
| 59 |
+
const next =
|
| 60 |
+
index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
|
| 61 |
+
buttons[(next + buttons.length) % buttons.length]?.focus()
|
| 62 |
+
e.preventDefault()
|
| 63 |
+
}
|
| 64 |
+
document.addEventListener("keydown", handleKeyDown)
|
| 65 |
+
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
return (
|
| 69 |
+
<DialogV2
|
| 70 |
+
fit
|
| 71 |
+
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
|
| 72 |
+
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
| 73 |
+
>
|
| 74 |
+
<DialogHeader closeLabel={language.t("common.close")}>
|
| 75 |
+
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
| 76 |
+
</DialogHeader>
|
| 77 |
+
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
| 78 |
+
<div ref={listEl} class="flex min-h-0 flex-col">
|
| 79 |
+
<div data-section="free-models" class="flex w-full flex-col items-start pb-3">
|
| 80 |
+
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
| 81 |
+
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
| 82 |
+
{language.t("dialog.model.unpaid.freeModels.title")}
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
<For each={freeModels()}>
|
| 86 |
+
{(item) => (
|
| 87 |
+
<TooltipV2
|
| 88 |
+
class="w-full"
|
| 89 |
+
placement="right-start"
|
| 90 |
+
gutter={6}
|
| 91 |
+
openDelay={0}
|
| 92 |
+
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
| 93 |
+
value={
|
| 94 |
+
<ModelTooltip
|
| 95 |
+
model={{ ...item, name: displayModelName(item.name) }}
|
| 96 |
+
latest={item.latest}
|
| 97 |
+
free={isFree(item)}
|
| 98 |
+
v2
|
| 99 |
+
/>
|
| 100 |
+
}
|
| 101 |
+
>
|
| 102 |
+
<button
|
| 103 |
+
type="button"
|
| 104 |
+
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
| 105 |
+
onClick={() => selectModel(item)}
|
| 106 |
+
>
|
| 107 |
+
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
|
| 108 |
+
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
| 109 |
+
<Show when={item.latest}>
|
| 110 |
+
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
| 111 |
+
</Show>
|
| 112 |
+
<Show when={currentKey() === modelKey(item)}>
|
| 113 |
+
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
| 114 |
+
</Show>
|
| 115 |
+
</button>
|
| 116 |
+
</TooltipV2>
|
| 117 |
+
)}
|
| 118 |
+
</For>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<div class="flex w-full flex-col">
|
| 122 |
+
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
|
| 123 |
+
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
|
| 124 |
+
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
| 125 |
+
{language.t("dialog.model.unpaid.addMore.title")}
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
| 129 |
+
<For
|
| 130 |
+
each={[...providers.popular()]
|
| 131 |
+
.filter((provider) => featuredProviders.includes(provider.id))
|
| 132 |
+
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
| 133 |
+
>
|
| 134 |
+
{(provider) => (
|
| 135 |
+
<button
|
| 136 |
+
type="button"
|
| 137 |
+
data-provider-id={provider.id}
|
| 138 |
+
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
| 139 |
+
classList={{
|
| 140 |
+
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
| 141 |
+
theme.mode() !== "dark",
|
| 142 |
+
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
|
| 143 |
+
}}
|
| 144 |
+
onClick={() => openProviders(provider.id)}
|
| 145 |
+
>
|
| 146 |
+
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
|
| 147 |
+
<span class="flex min-w-0 flex-col">
|
| 148 |
+
<span class="truncate">{provider.name}</span>
|
| 149 |
+
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
| 150 |
+
<span class="truncate font-[440] text-v2-text-text-muted">
|
| 151 |
+
{language.t(
|
| 152 |
+
provider.id === "opencode"
|
| 153 |
+
? "dialog.provider.opencode.tagline"
|
| 154 |
+
: "dialog.provider.opencodeGo.tagline",
|
| 155 |
+
)}
|
| 156 |
+
</span>
|
| 157 |
+
</Show>
|
| 158 |
+
</span>
|
| 159 |
+
</button>
|
| 160 |
+
)}
|
| 161 |
+
</For>
|
| 162 |
+
<button
|
| 163 |
+
type="button"
|
| 164 |
+
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
| 165 |
+
onClick={() => openProviders()}
|
| 166 |
+
>
|
| 167 |
+
{language.t("dialog.model.unpaid.viewMoreProviders")}
|
| 168 |
+
</button>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</DialogBody>
|
| 174 |
+
</DialogV2>
|
| 175 |
+
)
|
| 176 |
+
}
|
packages/app/src/components/dialog-select-model-unpaid.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from "@opencode-ai/ui/button"
|
| 2 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 3 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 4 |
+
import { List, type ListRef } from "@opencode-ai/ui/list"
|
| 5 |
+
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
| 6 |
+
import { Tag } from "@opencode-ai/ui/tag"
|
| 7 |
+
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
| 8 |
+
import { type Component, Show } from "solid-js"
|
| 9 |
+
import { useLocal } from "@/context/local"
|
| 10 |
+
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
| 11 |
+
import { ModelTooltip } from "./model-tooltip"
|
| 12 |
+
import { useLanguage } from "@/context/language"
|
| 13 |
+
import { decode64 } from "@/utils/base64"
|
| 14 |
+
|
| 15 |
+
type ModelState = ReturnType<typeof useLocal>["model"]
|
| 16 |
+
|
| 17 |
+
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
| 18 |
+
const local = useLocal()
|
| 19 |
+
const model = props.model ?? local.model
|
| 20 |
+
const dialog = useDialog()
|
| 21 |
+
const directory = () => decode64(local.slug())
|
| 22 |
+
const providers = useProviders(directory)
|
| 23 |
+
const language = useLanguage()
|
| 24 |
+
|
| 25 |
+
const openProviders = (provider?: string) => {
|
| 26 |
+
void import("./dialog-connect-provider").then((x) => {
|
| 27 |
+
const controller = x.useProviderConnectController()
|
| 28 |
+
controller.select(provider)
|
| 29 |
+
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
| 30 |
+
})
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const connect = (provider: string) => openProviders(provider)
|
| 34 |
+
const all = () => openProviders()
|
| 35 |
+
|
| 36 |
+
let listRef: ListRef | undefined
|
| 37 |
+
const handleKeyDown = (e: KeyboardEvent) => {
|
| 38 |
+
if (e.key === "Escape") return
|
| 39 |
+
listRef?.onKeyDown(e)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<Dialog
|
| 44 |
+
title={language.t("dialog.model.select.title")}
|
| 45 |
+
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
|
| 46 |
+
>
|
| 47 |
+
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
|
| 48 |
+
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
| 49 |
+
<List
|
| 50 |
+
class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
|
| 51 |
+
ref={(ref) => (listRef = ref)}
|
| 52 |
+
items={model.list}
|
| 53 |
+
current={model.current()}
|
| 54 |
+
key={(x) => `${x.provider.id}:${x.id}`}
|
| 55 |
+
itemWrapper={(item, node) => (
|
| 56 |
+
<Tooltip
|
| 57 |
+
class="w-full"
|
| 58 |
+
placement="right-start"
|
| 59 |
+
gutter={12}
|
| 60 |
+
value={
|
| 61 |
+
<ModelTooltip
|
| 62 |
+
model={item}
|
| 63 |
+
latest={item.latest}
|
| 64 |
+
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
| 65 |
+
/>
|
| 66 |
+
}
|
| 67 |
+
>
|
| 68 |
+
{node}
|
| 69 |
+
</Tooltip>
|
| 70 |
+
)}
|
| 71 |
+
onSelect={(x) => {
|
| 72 |
+
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
| 73 |
+
recent: true,
|
| 74 |
+
})
|
| 75 |
+
dialog.close()
|
| 76 |
+
}}
|
| 77 |
+
>
|
| 78 |
+
{(i) => (
|
| 79 |
+
<div class="w-full flex items-center gap-x-2.5">
|
| 80 |
+
<span>{i.name}</span>
|
| 81 |
+
<Tag>{language.t("model.tag.free")}</Tag>
|
| 82 |
+
<Show when={i.latest}>
|
| 83 |
+
<Tag>{language.t("model.tag.latest")}</Tag>
|
| 84 |
+
</Show>
|
| 85 |
+
</div>
|
| 86 |
+
)}
|
| 87 |
+
</List>
|
| 88 |
+
</div>
|
| 89 |
+
<div class="px-1.5 pb-1.5">
|
| 90 |
+
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
|
| 91 |
+
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
|
| 92 |
+
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
|
| 93 |
+
<div class="w-full">
|
| 94 |
+
<List
|
| 95 |
+
class="w-full px-3"
|
| 96 |
+
key={(p) => p.id}
|
| 97 |
+
items={providers.popular}
|
| 98 |
+
activeIcon="plus-small"
|
| 99 |
+
sortBy={(a, b) => {
|
| 100 |
+
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
| 101 |
+
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
| 102 |
+
return a.name.localeCompare(b.name)
|
| 103 |
+
}}
|
| 104 |
+
onSelect={(x) => {
|
| 105 |
+
if (!x) return
|
| 106 |
+
connect(x.id)
|
| 107 |
+
}}
|
| 108 |
+
>
|
| 109 |
+
{(i) => (
|
| 110 |
+
<div class="w-full flex items-center gap-x-3">
|
| 111 |
+
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
| 112 |
+
<span>{i.name}</span>
|
| 113 |
+
<Show when={i.id === "opencode"}>
|
| 114 |
+
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
| 115 |
+
</Show>
|
| 116 |
+
<Show when={i.id === "opencode"}>
|
| 117 |
+
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
| 118 |
+
</Show>
|
| 119 |
+
<Show when={i.id === "opencode-go"}>
|
| 120 |
+
<>
|
| 121 |
+
<div class="text-14-regular text-text-weak">
|
| 122 |
+
{language.t("dialog.provider.opencodeGo.tagline")}
|
| 123 |
+
</div>
|
| 124 |
+
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
| 125 |
+
</>
|
| 126 |
+
</Show>
|
| 127 |
+
<Show when={i.id === "anthropic"}>
|
| 128 |
+
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
|
| 129 |
+
</Show>
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
</List>
|
| 133 |
+
<Button
|
| 134 |
+
variant="ghost"
|
| 135 |
+
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
| 136 |
+
icon="dot-grid"
|
| 137 |
+
onClick={all}
|
| 138 |
+
>
|
| 139 |
+
{language.t("dialog.provider.viewAll")}
|
| 140 |
+
</Button>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</Dialog>
|
| 146 |
+
)
|
| 147 |
+
}
|
packages/app/src/components/dialog-select-server.tsx
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button } from "@opencode-ai/ui/button"
|
| 2 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 3 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 4 |
+
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
| 5 |
+
import { Icon } from "@opencode-ai/ui/icon"
|
| 6 |
+
import { IconButton } from "@opencode-ai/ui/icon-button"
|
| 7 |
+
import { List } from "@opencode-ai/ui/list"
|
| 8 |
+
import { TextField } from "@opencode-ai/ui/text-field"
|
| 9 |
+
import { useMutation } from "@tanstack/solid-query"
|
| 10 |
+
import { showToast } from "@/utils/toast"
|
| 11 |
+
import { useNavigate } from "@solidjs/router"
|
| 12 |
+
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
| 13 |
+
import { createStore } from "solid-js/store"
|
| 14 |
+
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
| 15 |
+
import { useGlobal } from "@/context/global"
|
| 16 |
+
import { useLanguage } from "@/context/language"
|
| 17 |
+
import { usePlatform } from "@/context/platform"
|
| 18 |
+
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
| 19 |
+
import { detectServerProtocol } from "@/utils/server-protocol"
|
| 20 |
+
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
| 21 |
+
import { useSettings } from "@/context/settings"
|
| 22 |
+
import { useTabs } from "@/context/tabs"
|
| 23 |
+
|
| 24 |
+
const DEFAULT_USERNAME = "opencode"
|
| 25 |
+
|
| 26 |
+
interface ServerFormProps {
|
| 27 |
+
value: string
|
| 28 |
+
name: string
|
| 29 |
+
username: string
|
| 30 |
+
password: string
|
| 31 |
+
placeholder: string
|
| 32 |
+
busy: boolean
|
| 33 |
+
error: string
|
| 34 |
+
status: boolean | undefined
|
| 35 |
+
onChange: (value: string) => void
|
| 36 |
+
onNameChange: (value: string) => void
|
| 37 |
+
onUsernameChange: (value: string) => void
|
| 38 |
+
onPasswordChange: (value: string) => void
|
| 39 |
+
onSubmit: () => void
|
| 40 |
+
onBack: () => void
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
| 44 |
+
showToast({
|
| 45 |
+
variant: "error",
|
| 46 |
+
title: language.t("common.requestFailed"),
|
| 47 |
+
description: err instanceof Error ? err.message : String(err),
|
| 48 |
+
})
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function useDefaultServer() {
|
| 52 |
+
const language = useLanguage()
|
| 53 |
+
const platform = usePlatform()
|
| 54 |
+
const [defaultKey, defaultUrlActions] = createResource(
|
| 55 |
+
async () => {
|
| 56 |
+
try {
|
| 57 |
+
const key = await platform.getDefaultServer?.()
|
| 58 |
+
if (!key) return null
|
| 59 |
+
return key
|
| 60 |
+
} catch (err) {
|
| 61 |
+
showRequestError(language, err)
|
| 62 |
+
return null
|
| 63 |
+
}
|
| 64 |
+
},
|
| 65 |
+
{ initialValue: null },
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
| 69 |
+
const setDefault = async (key: ServerConnection.Key | null) => {
|
| 70 |
+
try {
|
| 71 |
+
await platform.setDefaultServer?.(key)
|
| 72 |
+
defaultUrlActions.mutate(key)
|
| 73 |
+
} catch (err) {
|
| 74 |
+
showRequestError(language, err)
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function useServerPreview() {
|
| 82 |
+
const checkServerHealth = useCheckServerHealth()
|
| 83 |
+
|
| 84 |
+
const looksComplete = (value: string) => {
|
| 85 |
+
const normalized = normalizeServerUrl(value)
|
| 86 |
+
if (!normalized) return false
|
| 87 |
+
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
| 88 |
+
if (!host) return false
|
| 89 |
+
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
| 90 |
+
return host.includes(".") || host.includes(":")
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const previewStatus = async (
|
| 94 |
+
value: string,
|
| 95 |
+
username: string,
|
| 96 |
+
password: string,
|
| 97 |
+
setStatus: (value: boolean | undefined) => void,
|
| 98 |
+
) => {
|
| 99 |
+
setStatus(undefined)
|
| 100 |
+
if (!looksComplete(value)) return
|
| 101 |
+
const normalized = normalizeServerUrl(value)
|
| 102 |
+
if (!normalized) return
|
| 103 |
+
const http: ServerConnection.HttpBase = { url: normalized }
|
| 104 |
+
if (username) http.username = username
|
| 105 |
+
if (password) http.password = password
|
| 106 |
+
const result = await checkServerHealth(http)
|
| 107 |
+
setStatus(result.healthy)
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return { previewStatus }
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function ServerForm(props: ServerFormProps) {
|
| 114 |
+
const language = useLanguage()
|
| 115 |
+
const keyDown = (event: KeyboardEvent) => {
|
| 116 |
+
event.stopPropagation()
|
| 117 |
+
if (event.key === "Escape") {
|
| 118 |
+
event.preventDefault()
|
| 119 |
+
props.onBack()
|
| 120 |
+
return
|
| 121 |
+
}
|
| 122 |
+
if (event.key !== "Enter" || event.isComposing) return
|
| 123 |
+
event.preventDefault()
|
| 124 |
+
props.onSubmit()
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
return (
|
| 128 |
+
<div>
|
| 129 |
+
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
|
| 130 |
+
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
| 131 |
+
<TextField
|
| 132 |
+
type="text"
|
| 133 |
+
label={language.t("dialog.server.add.url")}
|
| 134 |
+
placeholder={props.placeholder}
|
| 135 |
+
value={props.value}
|
| 136 |
+
autofocus
|
| 137 |
+
validationState={props.error ? "invalid" : "valid"}
|
| 138 |
+
error={props.error}
|
| 139 |
+
disabled={props.busy}
|
| 140 |
+
onChange={props.onChange}
|
| 141 |
+
onKeyDown={keyDown}
|
| 142 |
+
/>
|
| 143 |
+
</div>
|
| 144 |
+
<TextField
|
| 145 |
+
type="text"
|
| 146 |
+
label={language.t("dialog.server.add.name")}
|
| 147 |
+
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
| 148 |
+
defaultValue={props.name}
|
| 149 |
+
disabled={props.busy}
|
| 150 |
+
onChange={props.onNameChange}
|
| 151 |
+
onKeyDown={keyDown}
|
| 152 |
+
/>
|
| 153 |
+
<div class="grid grid-cols-2 gap-2 min-w-0">
|
| 154 |
+
<TextField
|
| 155 |
+
type="text"
|
| 156 |
+
label={language.t("dialog.server.add.username")}
|
| 157 |
+
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
| 158 |
+
defaultValue={props.username}
|
| 159 |
+
disabled={props.busy}
|
| 160 |
+
onChange={props.onUsernameChange}
|
| 161 |
+
onKeyDown={keyDown}
|
| 162 |
+
/>
|
| 163 |
+
<TextField
|
| 164 |
+
type="password"
|
| 165 |
+
label={language.t("dialog.server.add.password")}
|
| 166 |
+
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
| 167 |
+
defaultValue={props.password}
|
| 168 |
+
disabled={props.busy}
|
| 169 |
+
onChange={props.onPasswordChange}
|
| 170 |
+
onKeyDown={keyDown}
|
| 171 |
+
/>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
export function DialogSelectServer() {
|
| 179 |
+
const dialog = useDialog()
|
| 180 |
+
const controller = useServerManagementController({ onSelect: dialog.close })
|
| 181 |
+
|
| 182 |
+
return (
|
| 183 |
+
<Dialog title={controller.formTitle()}>
|
| 184 |
+
<div class="flex flex-1 min-h-0 flex-col px-5">
|
| 185 |
+
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
|
| 186 |
+
<ServerConnectionForm controller={controller} />
|
| 187 |
+
</Show>
|
| 188 |
+
</div>
|
| 189 |
+
</Dialog>
|
| 190 |
+
)
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
| 194 |
+
const navigate = useNavigate()
|
| 195 |
+
const server = useServer()
|
| 196 |
+
const tabs = useTabs()
|
| 197 |
+
const global = useGlobal()
|
| 198 |
+
const platform = usePlatform()
|
| 199 |
+
const language = useLanguage()
|
| 200 |
+
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
| 201 |
+
const { previewStatus } = useServerPreview()
|
| 202 |
+
const checkServerHealth = useCheckServerHealth()
|
| 203 |
+
const [store, setStore] = createStore({
|
| 204 |
+
addServer: {
|
| 205 |
+
url: "",
|
| 206 |
+
name: "",
|
| 207 |
+
username: DEFAULT_USERNAME,
|
| 208 |
+
password: "",
|
| 209 |
+
error: "",
|
| 210 |
+
showForm: false,
|
| 211 |
+
status: undefined as boolean | undefined,
|
| 212 |
+
},
|
| 213 |
+
editServer: {
|
| 214 |
+
id: undefined as string | undefined,
|
| 215 |
+
value: "",
|
| 216 |
+
name: "",
|
| 217 |
+
username: "",
|
| 218 |
+
password: "",
|
| 219 |
+
error: "",
|
| 220 |
+
status: undefined as boolean | undefined,
|
| 221 |
+
},
|
| 222 |
+
})
|
| 223 |
+
|
| 224 |
+
const resetAdd = () => {
|
| 225 |
+
setStore("addServer", {
|
| 226 |
+
url: "",
|
| 227 |
+
name: "",
|
| 228 |
+
username: DEFAULT_USERNAME,
|
| 229 |
+
password: "",
|
| 230 |
+
error: "",
|
| 231 |
+
showForm: false,
|
| 232 |
+
status: undefined,
|
| 233 |
+
})
|
| 234 |
+
}
|
| 235 |
+
const resetEdit = () => {
|
| 236 |
+
setStore("editServer", {
|
| 237 |
+
id: undefined,
|
| 238 |
+
value: "",
|
| 239 |
+
name: "",
|
| 240 |
+
username: "",
|
| 241 |
+
password: "",
|
| 242 |
+
error: "",
|
| 243 |
+
status: undefined,
|
| 244 |
+
})
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
const addMutation = useMutation(() => ({
|
| 248 |
+
mutationFn: async (value: string) => {
|
| 249 |
+
const normalized = normalizeServerUrl(value)
|
| 250 |
+
if (!normalized) {
|
| 251 |
+
resetAdd()
|
| 252 |
+
return
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
const conn: ServerConnection.Http = {
|
| 256 |
+
type: "http",
|
| 257 |
+
http: { url: normalized },
|
| 258 |
+
}
|
| 259 |
+
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
| 260 |
+
if (store.addServer.password) conn.http.password = store.addServer.password
|
| 261 |
+
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
| 262 |
+
const result = await checkServerHealth(conn.http)
|
| 263 |
+
if (!result.healthy) {
|
| 264 |
+
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
| 265 |
+
return
|
| 266 |
+
}
|
| 267 |
+
if (
|
| 268 |
+
!settings.general.newLayoutDesigns() &&
|
| 269 |
+
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
| 270 |
+
) {
|
| 271 |
+
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
| 272 |
+
return
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
resetAdd()
|
| 276 |
+
if (options.navigateOnAdd === false) {
|
| 277 |
+
server.add(conn)
|
| 278 |
+
options.onSelect?.()
|
| 279 |
+
return
|
| 280 |
+
}
|
| 281 |
+
await select(conn, true)
|
| 282 |
+
},
|
| 283 |
+
}))
|
| 284 |
+
|
| 285 |
+
const editMutation = useMutation(() => ({
|
| 286 |
+
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
| 287 |
+
if (input.original.type !== "http") return
|
| 288 |
+
const normalized = normalizeServerUrl(input.value)
|
| 289 |
+
if (!normalized) {
|
| 290 |
+
resetEdit()
|
| 291 |
+
return
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
const name = store.editServer.name.trim() || undefined
|
| 295 |
+
const username = store.editServer.username || undefined
|
| 296 |
+
const password = store.editServer.password || undefined
|
| 297 |
+
const existingName = input.original.displayName
|
| 298 |
+
if (
|
| 299 |
+
normalized === input.original.http.url &&
|
| 300 |
+
name === existingName &&
|
| 301 |
+
username === input.original.http.username &&
|
| 302 |
+
password === input.original.http.password
|
| 303 |
+
) {
|
| 304 |
+
resetEdit()
|
| 305 |
+
return
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
const conn: ServerConnection.Http = {
|
| 309 |
+
type: "http",
|
| 310 |
+
displayName: name,
|
| 311 |
+
http: { url: normalized, username, password },
|
| 312 |
+
}
|
| 313 |
+
const result = await checkServerHealth(conn.http)
|
| 314 |
+
if (!result.healthy) {
|
| 315 |
+
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
| 316 |
+
return
|
| 317 |
+
}
|
| 318 |
+
if (
|
| 319 |
+
!settings.general.newLayoutDesigns() &&
|
| 320 |
+
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
| 321 |
+
) {
|
| 322 |
+
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
| 323 |
+
return
|
| 324 |
+
}
|
| 325 |
+
if (normalized === input.original.http.url) {
|
| 326 |
+
server.add(conn)
|
| 327 |
+
} else {
|
| 328 |
+
replaceServer(input.original, conn)
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
resetEdit()
|
| 332 |
+
},
|
| 333 |
+
}))
|
| 334 |
+
|
| 335 |
+
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
| 336 |
+
const originalKey = ServerConnection.key(original)
|
| 337 |
+
const active = server.key
|
| 338 |
+
tabs.removeServer(originalKey)
|
| 339 |
+
const newConn = server.add(next)
|
| 340 |
+
if (!newConn) return
|
| 341 |
+
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
|
| 342 |
+
if (nextActive) server.setActive(nextActive)
|
| 343 |
+
server.remove(originalKey)
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
const items = createMemo(() => {
|
| 347 |
+
const current = server.current
|
| 348 |
+
const list = server.list
|
| 349 |
+
if (!current) return list
|
| 350 |
+
if (!list.includes(current)) return [current, ...list]
|
| 351 |
+
return [current, ...list.filter((x) => x !== current)]
|
| 352 |
+
})
|
| 353 |
+
|
| 354 |
+
const settings = useSettings()
|
| 355 |
+
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
| 356 |
+
settings.general.newLayoutDesigns()
|
| 357 |
+
? undefined
|
| 358 |
+
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
const sortedItems = createMemo(() => {
|
| 362 |
+
const raw = items()
|
| 363 |
+
const list = settings.general.newLayoutDesigns()
|
| 364 |
+
? raw
|
| 365 |
+
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
| 366 |
+
if (!list.length) return list
|
| 367 |
+
const active = current()
|
| 368 |
+
const order = new Map(list.map((url, index) => [url, index] as const))
|
| 369 |
+
const rank = (value?: ServerHealth) => {
|
| 370 |
+
if (value?.healthy === true) return 0
|
| 371 |
+
if (value?.healthy === false) return 2
|
| 372 |
+
return 1
|
| 373 |
+
}
|
| 374 |
+
return list.slice().sort((a, b) => {
|
| 375 |
+
if (a === active) return -1
|
| 376 |
+
if (b === active) return 1
|
| 377 |
+
const diff =
|
| 378 |
+
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
| 379 |
+
if (diff !== 0) return diff
|
| 380 |
+
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
| 381 |
+
})
|
| 382 |
+
})
|
| 383 |
+
|
| 384 |
+
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
| 385 |
+
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
| 386 |
+
options.onSelect?.()
|
| 387 |
+
if (persist && conn.type === "http") {
|
| 388 |
+
server.add(conn)
|
| 389 |
+
navigate("/")
|
| 390 |
+
return
|
| 391 |
+
}
|
| 392 |
+
navigate("/")
|
| 393 |
+
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
const handleAddChange = (value: string) => {
|
| 397 |
+
if (addMutation.isPending) return
|
| 398 |
+
setStore("addServer", { url: value, error: "" })
|
| 399 |
+
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
| 400 |
+
setStore("addServer", { status: next }),
|
| 401 |
+
)
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
const handleAddNameChange = (value: string) => {
|
| 405 |
+
if (addMutation.isPending) return
|
| 406 |
+
setStore("addServer", { name: value, error: "" })
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
const handleAddUsernameChange = (value: string) => {
|
| 410 |
+
if (addMutation.isPending) return
|
| 411 |
+
setStore("addServer", { username: value, error: "" })
|
| 412 |
+
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
| 413 |
+
setStore("addServer", { status: next }),
|
| 414 |
+
)
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
const handleAddPasswordChange = (value: string) => {
|
| 418 |
+
if (addMutation.isPending) return
|
| 419 |
+
setStore("addServer", { password: value, error: "" })
|
| 420 |
+
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
| 421 |
+
setStore("addServer", { status: next }),
|
| 422 |
+
)
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
const handleEditChange = (value: string) => {
|
| 426 |
+
if (editMutation.isPending) return
|
| 427 |
+
setStore("editServer", { value, error: "" })
|
| 428 |
+
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
| 429 |
+
setStore("editServer", { status: next }),
|
| 430 |
+
)
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
const handleEditNameChange = (value: string) => {
|
| 434 |
+
if (editMutation.isPending) return
|
| 435 |
+
setStore("editServer", { name: value, error: "" })
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
const handleEditUsernameChange = (value: string) => {
|
| 439 |
+
if (editMutation.isPending) return
|
| 440 |
+
setStore("editServer", { username: value, error: "" })
|
| 441 |
+
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
| 442 |
+
setStore("editServer", { status: next }),
|
| 443 |
+
)
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
const handleEditPasswordChange = (value: string) => {
|
| 447 |
+
if (editMutation.isPending) return
|
| 448 |
+
setStore("editServer", { password: value, error: "" })
|
| 449 |
+
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
| 450 |
+
setStore("editServer", { status: next }),
|
| 451 |
+
)
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
const mode = createMemo<"list" | "add" | "edit">(() => {
|
| 455 |
+
if (store.editServer.id) return "edit"
|
| 456 |
+
if (store.addServer.showForm) return "add"
|
| 457 |
+
return "list"
|
| 458 |
+
})
|
| 459 |
+
|
| 460 |
+
const editing = createMemo(() => {
|
| 461 |
+
if (!store.editServer.id) return
|
| 462 |
+
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
| 463 |
+
})
|
| 464 |
+
|
| 465 |
+
const resetForm = () => {
|
| 466 |
+
resetAdd()
|
| 467 |
+
resetEdit()
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
const startAdd = () => {
|
| 471 |
+
resetEdit()
|
| 472 |
+
setStore("addServer", {
|
| 473 |
+
showForm: true,
|
| 474 |
+
url: "",
|
| 475 |
+
name: "",
|
| 476 |
+
username: DEFAULT_USERNAME,
|
| 477 |
+
password: "",
|
| 478 |
+
error: "",
|
| 479 |
+
status: undefined,
|
| 480 |
+
})
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
const startEdit = (conn: ServerConnection.Http) => {
|
| 484 |
+
resetAdd()
|
| 485 |
+
setStore("editServer", {
|
| 486 |
+
id: conn.http.url,
|
| 487 |
+
value: conn.http.url,
|
| 488 |
+
name: conn.displayName ?? "",
|
| 489 |
+
username: conn.http.username ?? "",
|
| 490 |
+
password: conn.http.password ?? "",
|
| 491 |
+
error: "",
|
| 492 |
+
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
|
| 493 |
+
})
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
const submitForm = () => {
|
| 497 |
+
if (mode() === "add") {
|
| 498 |
+
if (addMutation.isPending) return
|
| 499 |
+
setStore("addServer", { error: "" })
|
| 500 |
+
addMutation.mutate(store.addServer.url)
|
| 501 |
+
return
|
| 502 |
+
}
|
| 503 |
+
const original = editing()
|
| 504 |
+
if (!original) return
|
| 505 |
+
if (editMutation.isPending) return
|
| 506 |
+
setStore("editServer", { error: "" })
|
| 507 |
+
editMutation.mutate({ original, value: store.editServer.value })
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
const isFormMode = createMemo(() => mode() !== "list")
|
| 511 |
+
const isAddMode = createMemo(() => mode() === "add")
|
| 512 |
+
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
| 513 |
+
|
| 514 |
+
const formTitle = createMemo(() => {
|
| 515 |
+
if (!isFormMode()) return language.t("dialog.server.title")
|
| 516 |
+
return (
|
| 517 |
+
<div class="flex items-center gap-2 -ml-2">
|
| 518 |
+
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
| 519 |
+
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
| 520 |
+
</div>
|
| 521 |
+
)
|
| 522 |
+
})
|
| 523 |
+
|
| 524 |
+
createEffect(() => {
|
| 525 |
+
if (!store.editServer.id) return
|
| 526 |
+
if (editing()) return
|
| 527 |
+
resetEdit()
|
| 528 |
+
})
|
| 529 |
+
|
| 530 |
+
async function handleRemove(key: ServerConnection.Key) {
|
| 531 |
+
try {
|
| 532 |
+
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
| 533 |
+
tabs.removeServer(key)
|
| 534 |
+
server.remove(key)
|
| 535 |
+
if ((await platform.getDefaultServer?.()) === key) {
|
| 536 |
+
await setDefault(null)
|
| 537 |
+
}
|
| 538 |
+
} catch (err) {
|
| 539 |
+
showRequestError(language, err)
|
| 540 |
+
}
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
return {
|
| 544 |
+
defaultKey,
|
| 545 |
+
canDefault,
|
| 546 |
+
current,
|
| 547 |
+
sortedItems,
|
| 548 |
+
status: () => global.servers.health,
|
| 549 |
+
isFormMode,
|
| 550 |
+
isAddMode,
|
| 551 |
+
formTitle,
|
| 552 |
+
formBusy,
|
| 553 |
+
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
|
| 554 |
+
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
|
| 555 |
+
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
|
| 556 |
+
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
|
| 557 |
+
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
|
| 558 |
+
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
|
| 559 |
+
select,
|
| 560 |
+
setDefault,
|
| 561 |
+
startAdd,
|
| 562 |
+
startEdit,
|
| 563 |
+
resetForm,
|
| 564 |
+
submitForm,
|
| 565 |
+
handleRemove,
|
| 566 |
+
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
| 567 |
+
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
| 568 |
+
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
|
| 569 |
+
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
|
| 570 |
+
}
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
| 574 |
+
const language = useLanguage()
|
| 575 |
+
const settings = useSettings()
|
| 576 |
+
|
| 577 |
+
return (
|
| 578 |
+
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
| 579 |
+
<List
|
| 580 |
+
class="flex-1 min-h-0 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
| 581 |
+
search={{
|
| 582 |
+
placeholder: language.t("dialog.server.search.placeholder"),
|
| 583 |
+
autofocus: false,
|
| 584 |
+
}}
|
| 585 |
+
noInitialSelection
|
| 586 |
+
emptyMessage={language.t("dialog.server.empty")}
|
| 587 |
+
items={props.controller.sortedItems}
|
| 588 |
+
key={(x) => x.http.url}
|
| 589 |
+
onSelect={(x) => {
|
| 590 |
+
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
|
| 591 |
+
}}
|
| 592 |
+
divider={true}
|
| 593 |
+
>
|
| 594 |
+
{(i) => {
|
| 595 |
+
const key = ServerConnection.key(i)
|
| 596 |
+
return (
|
| 597 |
+
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
| 598 |
+
<div class="flex flex-col h-full items-center w-5">
|
| 599 |
+
<ServerHealthIndicator health={props.controller.status()[key]} />
|
| 600 |
+
</div>
|
| 601 |
+
<ServerRow
|
| 602 |
+
conn={i}
|
| 603 |
+
dimmed={props.controller.status()[key]?.healthy === false}
|
| 604 |
+
status={props.controller.status()[key]}
|
| 605 |
+
class="flex items-center gap-3 min-w-0 flex-1"
|
| 606 |
+
badge={
|
| 607 |
+
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
|
| 608 |
+
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
| 609 |
+
{language.t("dialog.server.status.default")}
|
| 610 |
+
</span>
|
| 611 |
+
</Show>
|
| 612 |
+
}
|
| 613 |
+
showCredentials
|
| 614 |
+
/>
|
| 615 |
+
<div class="flex items-center justify-center gap-4 pl-4">
|
| 616 |
+
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
|
| 617 |
+
<Icon name="check" class="h-6" />
|
| 618 |
+
</Show>
|
| 619 |
+
|
| 620 |
+
<Show when={i.type === "http"}>
|
| 621 |
+
<DropdownMenu>
|
| 622 |
+
<DropdownMenu.Trigger
|
| 623 |
+
as={IconButton}
|
| 624 |
+
icon="dot-grid"
|
| 625 |
+
variant="ghost"
|
| 626 |
+
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
| 627 |
+
onClick={(e: MouseEvent) => e.stopPropagation()}
|
| 628 |
+
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
| 629 |
+
/>
|
| 630 |
+
<DropdownMenu.Portal>
|
| 631 |
+
<DropdownMenu.Content class="mt-1">
|
| 632 |
+
<DropdownMenu.Item
|
| 633 |
+
onSelect={() => {
|
| 634 |
+
if (i.type !== "http") return
|
| 635 |
+
props.controller.startEdit(i)
|
| 636 |
+
}}
|
| 637 |
+
>
|
| 638 |
+
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
| 639 |
+
</DropdownMenu.Item>
|
| 640 |
+
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
| 641 |
+
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
|
| 642 |
+
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
| 643 |
+
</DropdownMenu.Item>
|
| 644 |
+
</Show>
|
| 645 |
+
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
| 646 |
+
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
|
| 647 |
+
<DropdownMenu.ItemLabel>
|
| 648 |
+
{language.t("dialog.server.menu.defaultRemove")}
|
| 649 |
+
</DropdownMenu.ItemLabel>
|
| 650 |
+
</DropdownMenu.Item>
|
| 651 |
+
</Show>
|
| 652 |
+
<DropdownMenu.Separator />
|
| 653 |
+
<DropdownMenu.Item
|
| 654 |
+
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
| 655 |
+
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
| 656 |
+
>
|
| 657 |
+
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
| 658 |
+
</DropdownMenu.Item>
|
| 659 |
+
</DropdownMenu.Content>
|
| 660 |
+
</DropdownMenu.Portal>
|
| 661 |
+
</DropdownMenu>
|
| 662 |
+
</Show>
|
| 663 |
+
</div>
|
| 664 |
+
</div>
|
| 665 |
+
)
|
| 666 |
+
}}
|
| 667 |
+
</List>
|
| 668 |
+
|
| 669 |
+
<div class="shrink-0 pb-5">
|
| 670 |
+
<Button
|
| 671 |
+
variant="secondary"
|
| 672 |
+
icon="plus-small"
|
| 673 |
+
size="large"
|
| 674 |
+
onClick={props.controller.startAdd}
|
| 675 |
+
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
| 676 |
+
>
|
| 677 |
+
{language.t("dialog.server.add.button")}
|
| 678 |
+
</Button>
|
| 679 |
+
</div>
|
| 680 |
+
</div>
|
| 681 |
+
)
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
| 685 |
+
const language = useLanguage()
|
| 686 |
+
|
| 687 |
+
return (
|
| 688 |
+
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
| 689 |
+
<ServerForm
|
| 690 |
+
value={props.controller.formValue()}
|
| 691 |
+
name={props.controller.formName()}
|
| 692 |
+
username={props.controller.formUsername()}
|
| 693 |
+
password={props.controller.formPassword()}
|
| 694 |
+
placeholder={language.t("dialog.server.add.placeholder")}
|
| 695 |
+
busy={props.controller.formBusy()}
|
| 696 |
+
error={props.controller.formError()}
|
| 697 |
+
status={props.controller.formStatus()}
|
| 698 |
+
onChange={props.controller.handleFormChange()}
|
| 699 |
+
onNameChange={props.controller.handleFormNameChange()}
|
| 700 |
+
onUsernameChange={props.controller.handleFormUsernameChange()}
|
| 701 |
+
onPasswordChange={props.controller.handleFormPasswordChange()}
|
| 702 |
+
onSubmit={props.controller.submitForm}
|
| 703 |
+
onBack={props.controller.resetForm}
|
| 704 |
+
/>
|
| 705 |
+
<div class="shrink-0 pb-5">
|
| 706 |
+
<Button
|
| 707 |
+
variant="primary"
|
| 708 |
+
size="large"
|
| 709 |
+
onClick={props.controller.submitForm}
|
| 710 |
+
disabled={props.controller.formBusy()}
|
| 711 |
+
class="px-3 py-1.5"
|
| 712 |
+
>
|
| 713 |
+
{props.controller.formBusy()
|
| 714 |
+
? language.t("dialog.server.add.checking")
|
| 715 |
+
: props.controller.isAddMode()
|
| 716 |
+
? language.t("dialog.server.add.button")
|
| 717 |
+
: language.t("common.save")}
|
| 718 |
+
</Button>
|
| 719 |
+
</div>
|
| 720 |
+
</div>
|
| 721 |
+
)
|
| 722 |
+
}
|
packages/app/src/components/dialog-settings.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Component, createSignal, startTransition } from "solid-js"
|
| 2 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 3 |
+
import { Tabs } from "@opencode-ai/ui/tabs"
|
| 4 |
+
import { Icon } from "@opencode-ai/ui/icon"
|
| 5 |
+
import { useLanguage } from "@/context/language"
|
| 6 |
+
import { usePlatform } from "@/context/platform"
|
| 7 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 8 |
+
import { SettingsGeneral } from "./settings-general"
|
| 9 |
+
import { SettingsKeybinds } from "./settings-keybinds"
|
| 10 |
+
import { SettingsProviders } from "./settings-providers"
|
| 11 |
+
import { SettingsModels } from "./settings-models"
|
| 12 |
+
import { SettingsServers } from "./settings-servers"
|
| 13 |
+
|
| 14 |
+
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
|
| 15 |
+
const language = useLanguage()
|
| 16 |
+
const platform = usePlatform()
|
| 17 |
+
const dialog = useDialog()
|
| 18 |
+
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
| 19 |
+
|
| 20 |
+
const showProviders = () => {
|
| 21 |
+
void dialog.show(() => <DialogSettings defaultValue="providers" />)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<Dialog size="x-large" transition>
|
| 26 |
+
<Tabs
|
| 27 |
+
orientation="vertical"
|
| 28 |
+
variant="settings"
|
| 29 |
+
value={tab()}
|
| 30 |
+
onChange={(value) => void startTransition(() => setTab(value))}
|
| 31 |
+
class="h-full settings-dialog"
|
| 32 |
+
>
|
| 33 |
+
<Tabs.List>
|
| 34 |
+
<div class="flex flex-col justify-between h-full w-full gap-4">
|
| 35 |
+
<div class="flex flex-col gap-3 w-full pt-3">
|
| 36 |
+
<div class="flex flex-col gap-3">
|
| 37 |
+
<div class="flex flex-col gap-1.5">
|
| 38 |
+
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
|
| 39 |
+
<div class="flex flex-col gap-1.5 w-full">
|
| 40 |
+
<Tabs.Trigger value="general">
|
| 41 |
+
<Icon name="sliders" />
|
| 42 |
+
{language.t("settings.tab.general")}
|
| 43 |
+
</Tabs.Trigger>
|
| 44 |
+
<Tabs.Trigger value="shortcuts">
|
| 45 |
+
<Icon name="keyboard" />
|
| 46 |
+
{language.t("settings.tab.shortcuts")}
|
| 47 |
+
</Tabs.Trigger>
|
| 48 |
+
<Tabs.Trigger value="servers">
|
| 49 |
+
<Icon name="server" />
|
| 50 |
+
{language.t("status.popover.tab.servers")}
|
| 51 |
+
</Tabs.Trigger>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div class="flex flex-col gap-1.5">
|
| 56 |
+
<Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
|
| 57 |
+
<div class="flex flex-col gap-1.5 w-full">
|
| 58 |
+
<Tabs.Trigger value="providers">
|
| 59 |
+
<Icon name="providers" />
|
| 60 |
+
{language.t("settings.providers.title")}
|
| 61 |
+
</Tabs.Trigger>
|
| 62 |
+
<Tabs.Trigger value="models">
|
| 63 |
+
<Icon name="models" />
|
| 64 |
+
{language.t("settings.models.title")}
|
| 65 |
+
</Tabs.Trigger>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
|
| 71 |
+
<span>{language.t("app.name.desktop")}</span>
|
| 72 |
+
<span class="text-11-regular">v{platform.version}</span>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</Tabs.List>
|
| 76 |
+
<Tabs.Content value="general" class="no-scrollbar">
|
| 77 |
+
<SettingsGeneral />
|
| 78 |
+
</Tabs.Content>
|
| 79 |
+
<Tabs.Content value="shortcuts" class="no-scrollbar">
|
| 80 |
+
<SettingsKeybinds />
|
| 81 |
+
</Tabs.Content>
|
| 82 |
+
<Tabs.Content value="servers" class="no-scrollbar">
|
| 83 |
+
<SettingsServers />
|
| 84 |
+
</Tabs.Content>
|
| 85 |
+
<Tabs.Content value="providers" class="no-scrollbar">
|
| 86 |
+
<SettingsProviders onBack={showProviders} />
|
| 87 |
+
</Tabs.Content>
|
| 88 |
+
<Tabs.Content value="models" class="no-scrollbar">
|
| 89 |
+
<SettingsModels />
|
| 90 |
+
</Tabs.Content>
|
| 91 |
+
</Tabs>
|
| 92 |
+
</Dialog>
|
| 93 |
+
)
|
| 94 |
+
}
|
packages/app/src/components/dialog-usage-exceeded.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { usePlatform } from "@/context/platform"
|
| 2 |
+
import { useLanguage } from "@/context/language"
|
| 3 |
+
import { Button } from "@opencode-ai/ui/button"
|
| 4 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 5 |
+
import { Dialog } from "@opencode-ai/ui/dialog"
|
| 6 |
+
import { JSX } from "solid-js"
|
| 7 |
+
|
| 8 |
+
export type DialogGoUpsellProps = {
|
| 9 |
+
title: string
|
| 10 |
+
description: JSX.Element
|
| 11 |
+
link?: string
|
| 12 |
+
actionLabel: string
|
| 13 |
+
onClose?: (dontShowAgain?: boolean) => void
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function DialogUsageExceeded(props: DialogGoUpsellProps) {
|
| 17 |
+
const dialog = useDialog()
|
| 18 |
+
const language = useLanguage()
|
| 19 |
+
const platform = usePlatform()
|
| 20 |
+
|
| 21 |
+
const runAction = () => {
|
| 22 |
+
if (props.link) platform.openExternal(props.link)
|
| 23 |
+
props.onClose?.()
|
| 24 |
+
dialog.close()
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const dismiss = () => {
|
| 28 |
+
props.onClose?.(true)
|
| 29 |
+
dialog.close()
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<Dialog title={props.title} description={props.description} fit>
|
| 34 |
+
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
| 35 |
+
<div class="flex justify-end gap-2">
|
| 36 |
+
<Button variant="ghost" size="large" onClick={dismiss}>
|
| 37 |
+
{language.t("dialog.usageExceeded.dontShowAgain")}
|
| 38 |
+
</Button>
|
| 39 |
+
<Button variant="primary" size="large" onClick={runAction}>
|
| 40 |
+
{props.actionLabel}
|
| 41 |
+
</Button>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
</Dialog>
|
| 45 |
+
)
|
| 46 |
+
}
|
packages/app/src/components/directory-picker-domain.test.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import {
|
| 3 |
+
absoluteTreePath,
|
| 4 |
+
activeTreeNavigation,
|
| 5 |
+
advanceTreePreload,
|
| 6 |
+
nextSuggestionIndex,
|
| 7 |
+
nextTreeScrollTop,
|
| 8 |
+
pickerTreeEntries,
|
| 9 |
+
pickerSearchEntries,
|
| 10 |
+
pickerFileSearchQuery,
|
| 11 |
+
pickerMode,
|
| 12 |
+
preloadTreeDirectories,
|
| 13 |
+
selectedTreePath,
|
| 14 |
+
treeEntries,
|
| 15 |
+
treePathWithin,
|
| 16 |
+
currentPickerSuggestions,
|
| 17 |
+
createDirectorySearch,
|
| 18 |
+
createPriorityTaskQueue,
|
| 19 |
+
displayPickerPath,
|
| 20 |
+
pickerParent,
|
| 21 |
+
pickerRoot,
|
| 22 |
+
pickerAbsoluteInput,
|
| 23 |
+
} from "./directory-picker-domain"
|
| 24 |
+
|
| 25 |
+
test("maps server directory entries into Pierre paths", () => {
|
| 26 |
+
expect(
|
| 27 |
+
treeEntries("src/", [
|
| 28 |
+
{ name: "components", type: "directory" },
|
| 29 |
+
{ name: "index.ts", type: "file" },
|
| 30 |
+
]),
|
| 31 |
+
).toEqual(["src/components/", "src/index.ts"])
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
test("maps Pierre paths back to the selected server root", () => {
|
| 35 |
+
expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components")
|
| 36 |
+
expect(absoluteTreePath("C:/", "")).toBe("C:/")
|
| 37 |
+
expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md")
|
| 38 |
+
expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md")
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
test("includes files only when the picker selects files", () => {
|
| 42 |
+
const nodes = [
|
| 43 |
+
{ name: "components", type: "directory" as const },
|
| 44 |
+
{ name: "index.ts", type: "file" as const },
|
| 45 |
+
]
|
| 46 |
+
expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"])
|
| 47 |
+
expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"])
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
test("includes files in file autocomplete while preserving directory navigation", () => {
|
| 51 |
+
const nodes = [
|
| 52 |
+
{ name: "src", absolute: "/repo/src", type: "directory" as const },
|
| 53 |
+
{ name: "README.md", absolute: "/repo/README.md", type: "file" as const },
|
| 54 |
+
]
|
| 55 |
+
expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]])
|
| 56 |
+
expect(pickerSearchEntries(nodes, "file")).toEqual(nodes)
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
test("centralizes file and directory selection policy", () => {
|
| 60 |
+
const file = pickerMode("file", "/repo")
|
| 61 |
+
expect(file.includeFiles).toBeTrue()
|
| 62 |
+
expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts")
|
| 63 |
+
expect(file.selection("/repo", "src/")).toBeUndefined()
|
| 64 |
+
expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts")
|
| 65 |
+
expect(file.selection("/tmp", "example.txt")).toBeUndefined()
|
| 66 |
+
expect(file.navigation("/repo/src")).toBe("/repo/src")
|
| 67 |
+
expect(file.navigation("/tmp")).toBeUndefined()
|
| 68 |
+
|
| 69 |
+
const directory = pickerMode("directory")
|
| 70 |
+
expect(directory.includeFiles).toBeFalse()
|
| 71 |
+
expect(directory.selection("/repo", "src/")).toBe("/repo/src")
|
| 72 |
+
expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos")
|
| 73 |
+
expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo")
|
| 74 |
+
expect(directory.navigation("/tmp")).toBe("/tmp")
|
| 75 |
+
expect(directory.result("/repo", "")).toBe("/repo")
|
| 76 |
+
expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke")
|
| 77 |
+
expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo")
|
| 78 |
+
expect(directory.result("/repo", "", false)).toBeUndefined()
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
test("accepts mutations only from the active navigation", () => {
|
| 82 |
+
expect(activeTreeNavigation(3, 3)).toBeTrue()
|
| 83 |
+
expect(activeTreeNavigation(2, 3)).toBeFalse()
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
test("preserves POSIX case while matching Windows drives case-insensitively", () => {
|
| 87 |
+
expect(treePathWithin("/repo", "/Repo")).toBeFalse()
|
| 88 |
+
expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue()
|
| 89 |
+
expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue()
|
| 90 |
+
expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe("src/file.ts")
|
| 91 |
+
expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse()
|
| 92 |
+
expect(treePathWithin("/", "/src")).toBeTrue()
|
| 93 |
+
expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts")
|
| 94 |
+
expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts")
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
test("displays paths using the selected server path format", () => {
|
| 98 |
+
expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe(
|
| 99 |
+
"C:\\Users\\luke\\repos",
|
| 100 |
+
)
|
| 101 |
+
expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe(
|
| 102 |
+
"C:\\Users\\luke\\repos",
|
| 103 |
+
)
|
| 104 |
+
expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos")
|
| 105 |
+
expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos")
|
| 106 |
+
})
|
| 107 |
+
|
| 108 |
+
test("treats the server share prefix as the UNC root", () => {
|
| 109 |
+
expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share")
|
| 110 |
+
expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share")
|
| 111 |
+
expect(pickerParent("//Server/Share")).toBe("//Server/Share")
|
| 112 |
+
expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share")
|
| 113 |
+
})
|
| 114 |
+
|
| 115 |
+
test("resolves relative input against the current picker root", () => {
|
| 116 |
+
expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src")
|
| 117 |
+
expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other")
|
| 118 |
+
expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config")
|
| 119 |
+
expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src")
|
| 120 |
+
})
|
| 121 |
+
|
| 122 |
+
test("exposes autocomplete results only for their source query", () => {
|
| 123 |
+
const result = { query: "/repo/src", items: ["/repo/src/index.ts"] }
|
| 124 |
+
expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items)
|
| 125 |
+
expect(currentPickerSuggestions(result, "/repo/test")).toEqual([])
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
test("scopes file autocomplete to the current browser root", () => {
|
| 129 |
+
expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in")
|
| 130 |
+
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
test("resolves directory autocomplete from the current browser root", async () => {
|
| 134 |
+
const directories: string[] = []
|
| 135 |
+
const sdk = {
|
| 136 |
+
api: {
|
| 137 |
+
file: {
|
| 138 |
+
find: (input: { location?: { directory?: string } }) => {
|
| 139 |
+
directories.push(input.location?.directory ?? "")
|
| 140 |
+
return Promise.resolve({ data: [] })
|
| 141 |
+
},
|
| 142 |
+
list: () => Promise.resolve({ data: [] }),
|
| 143 |
+
},
|
| 144 |
+
},
|
| 145 |
+
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
| 146 |
+
let base = "/repo"
|
| 147 |
+
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
|
| 148 |
+
|
| 149 |
+
await search("components")
|
| 150 |
+
base = "/repo/src"
|
| 151 |
+
await search("components")
|
| 152 |
+
|
| 153 |
+
expect(directories).toEqual(["/repo", "/repo/src"])
|
| 154 |
+
})
|
| 155 |
+
|
| 156 |
+
test("keeps indexed directory results for servers that support empty search", async () => {
|
| 157 |
+
const sdk = {
|
| 158 |
+
api: {
|
| 159 |
+
file: {
|
| 160 |
+
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
|
| 161 |
+
list: () => Promise.reject(new Error("listing should not run when search returns results")),
|
| 162 |
+
},
|
| 163 |
+
},
|
| 164 |
+
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
| 165 |
+
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
| 166 |
+
|
| 167 |
+
expect(await search("")).toEqual(["/home/luke/projects"])
|
| 168 |
+
})
|
| 169 |
+
|
| 170 |
+
test("lists the default directory when empty search is unsupported", async () => {
|
| 171 |
+
const calls: string[] = []
|
| 172 |
+
const directories = Array.from({ length: 60 }, (_, index) => ({
|
| 173 |
+
path: `project-${index}/`,
|
| 174 |
+
type: "directory" as const,
|
| 175 |
+
}))
|
| 176 |
+
const sdk = {
|
| 177 |
+
api: {
|
| 178 |
+
file: {
|
| 179 |
+
find: () => Promise.resolve({ data: [] }),
|
| 180 |
+
list: (input: { location?: { directory?: string } }) => {
|
| 181 |
+
calls.push(input.location?.directory ?? "")
|
| 182 |
+
return Promise.resolve({
|
| 183 |
+
data: [...directories, { path: "README.md", type: "file" }],
|
| 184 |
+
})
|
| 185 |
+
},
|
| 186 |
+
},
|
| 187 |
+
},
|
| 188 |
+
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
| 189 |
+
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
| 190 |
+
|
| 191 |
+
const results = await search("")
|
| 192 |
+
expect(results).toHaveLength(60)
|
| 193 |
+
expect(results.at(-1)).toBe("/home/luke/project-59")
|
| 194 |
+
expect(calls).toEqual(["/home/luke"])
|
| 195 |
+
})
|
| 196 |
+
|
| 197 |
+
test("matches the default directory listing when typed search is unsupported", async () => {
|
| 198 |
+
const sdk = {
|
| 199 |
+
api: {
|
| 200 |
+
file: {
|
| 201 |
+
find: () => Promise.resolve({ data: [] }),
|
| 202 |
+
list: () =>
|
| 203 |
+
Promise.resolve({
|
| 204 |
+
data: [
|
| 205 |
+
{ path: "Documents/", type: "directory" },
|
| 206 |
+
{ path: "Downloads/", type: "directory" },
|
| 207 |
+
],
|
| 208 |
+
}),
|
| 209 |
+
},
|
| 210 |
+
},
|
| 211 |
+
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
| 212 |
+
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
| 213 |
+
|
| 214 |
+
expect(await search("documents")).toEqual(["/home/luke/Documents"])
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
test("searches from an absolute root without a default base", async () => {
|
| 218 |
+
const directories: string[] = []
|
| 219 |
+
const sdk = {
|
| 220 |
+
api: {
|
| 221 |
+
file: {
|
| 222 |
+
list: (input: { location?: { directory?: string } }) => {
|
| 223 |
+
directories.push(input.location?.directory ?? "")
|
| 224 |
+
return Promise.resolve({
|
| 225 |
+
data: [
|
| 226 |
+
{ path: "Users/", type: "directory" },
|
| 227 |
+
{ path: "tmp/", type: "directory" },
|
| 228 |
+
],
|
| 229 |
+
})
|
| 230 |
+
},
|
| 231 |
+
},
|
| 232 |
+
},
|
| 233 |
+
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
| 234 |
+
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
|
| 235 |
+
|
| 236 |
+
expect(await search("/")).toEqual(["/Users", "/tmp"])
|
| 237 |
+
expect(directories).toEqual(["/"])
|
| 238 |
+
})
|
| 239 |
+
|
| 240 |
+
test("identifies the next directory level to preload", () => {
|
| 241 |
+
expect(
|
| 242 |
+
preloadTreeDirectories("src/", [
|
| 243 |
+
{ name: "components", type: "directory" },
|
| 244 |
+
{ name: "index.ts", type: "file" },
|
| 245 |
+
{ name: "utils", type: "directory" },
|
| 246 |
+
]),
|
| 247 |
+
).toEqual(["src/components/", "src/utils/"])
|
| 248 |
+
})
|
| 249 |
+
|
| 250 |
+
test("advances preloading once for every expanded directory", () => {
|
| 251 |
+
const advanced = new Set<string>()
|
| 252 |
+
expect(advanceTreePreload(advanced, "")).toBeTrue()
|
| 253 |
+
expect(advanceTreePreload(advanced, "")).toBeFalse()
|
| 254 |
+
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
|
| 255 |
+
})
|
| 256 |
+
|
| 257 |
+
test("limits background tasks and prioritizes newly requested work", async () => {
|
| 258 |
+
const queue = createPriorityTaskQueue<void>(2)
|
| 259 |
+
const first = Promise.withResolvers<void>()
|
| 260 |
+
const second = Promise.withResolvers<void>()
|
| 261 |
+
const started: string[] = []
|
| 262 |
+
let active = 0
|
| 263 |
+
let maximum = 0
|
| 264 |
+
const task = (name: string, blocker?: Promise<void>) => async () => {
|
| 265 |
+
started.push(name)
|
| 266 |
+
active++
|
| 267 |
+
maximum = Math.max(maximum, active)
|
| 268 |
+
await blocker
|
| 269 |
+
active--
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
const running = [
|
| 273 |
+
queue.schedule("first", "background", task("first", first.promise)),
|
| 274 |
+
queue.schedule("second", "background", task("second", second.promise)),
|
| 275 |
+
queue.schedule("preload", "background", task("preload")),
|
| 276 |
+
queue.schedule("opened", "user", task("opened")),
|
| 277 |
+
]
|
| 278 |
+
await Promise.resolve()
|
| 279 |
+
expect(started).toEqual(["first", "second"])
|
| 280 |
+
|
| 281 |
+
first.resolve()
|
| 282 |
+
await running[0]
|
| 283 |
+
await Promise.resolve()
|
| 284 |
+
expect(started).toEqual(["first", "second", "opened"])
|
| 285 |
+
|
| 286 |
+
second.resolve()
|
| 287 |
+
await Promise.all(running)
|
| 288 |
+
expect(started).toEqual(["first", "second", "opened", "preload"])
|
| 289 |
+
expect(maximum).toBe(2)
|
| 290 |
+
})
|
| 291 |
+
|
| 292 |
+
test("clamps bridged tree wheel scrolling", () => {
|
| 293 |
+
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
|
| 294 |
+
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
|
| 295 |
+
expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300)
|
| 296 |
+
})
|
| 297 |
+
|
| 298 |
+
test("wraps autocomplete keyboard navigation", () => {
|
| 299 |
+
expect(nextSuggestionIndex(-1, 1, 4)).toBe(0)
|
| 300 |
+
expect(nextSuggestionIndex(3, 1, 4)).toBe(0)
|
| 301 |
+
expect(nextSuggestionIndex(0, -1, 4)).toBe(3)
|
| 302 |
+
expect(nextSuggestionIndex(0, 1, 0)).toBe(-1)
|
| 303 |
+
})
|
| 304 |
+
|
| 305 |
+
test("returns absolute directories and relative files", () => {
|
| 306 |
+
expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src")
|
| 307 |
+
expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts")
|
| 308 |
+
expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts")
|
| 309 |
+
expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined()
|
| 310 |
+
})
|
packages/app/src/components/directory-picker-domain.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
| 2 |
+
const prefix = parent.replace(/^\/+|\/+$/g, "")
|
| 3 |
+
return nodes.map((node) => {
|
| 4 |
+
const path = prefix ? `${prefix}/${node.name}` : node.name
|
| 5 |
+
return node.type === "directory" ? path + "/" : path
|
| 6 |
+
})
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function pickerTreeEntries(
|
| 10 |
+
parent: string,
|
| 11 |
+
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
|
| 12 |
+
mode: "directory" | "file",
|
| 13 |
+
) {
|
| 14 |
+
return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes)
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function pickerSearchEntries<T extends { type: "file" | "directory" }>(
|
| 18 |
+
nodes: readonly T[],
|
| 19 |
+
mode: "directory" | "file",
|
| 20 |
+
) {
|
| 21 |
+
return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes]
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function pickerMode(mode: "directory" | "file", base?: string) {
|
| 25 |
+
if (mode === "file") {
|
| 26 |
+
return {
|
| 27 |
+
includeFiles: true,
|
| 28 |
+
action: "file" as const,
|
| 29 |
+
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
| 30 |
+
return treeEntries(parent, nodes)
|
| 31 |
+
},
|
| 32 |
+
navigation(path: string) {
|
| 33 |
+
return treePathWithin(base, path) ? path : undefined
|
| 34 |
+
},
|
| 35 |
+
result(root: string, selected: string) {
|
| 36 |
+
return selected || undefined
|
| 37 |
+
},
|
| 38 |
+
selection(root: string, path: string) {
|
| 39 |
+
if (!treePathWithin(base, root)) return
|
| 40 |
+
return selectedTreePath(root, path, "file", base)
|
| 41 |
+
},
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
return {
|
| 45 |
+
includeFiles: false,
|
| 46 |
+
action: "directory" as const,
|
| 47 |
+
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
| 48 |
+
return treeEntries(
|
| 49 |
+
parent,
|
| 50 |
+
nodes.filter((node) => node.type === "directory"),
|
| 51 |
+
)
|
| 52 |
+
},
|
| 53 |
+
navigation(path: string) {
|
| 54 |
+
return path
|
| 55 |
+
},
|
| 56 |
+
result(root: string, selected: string, valid = true) {
|
| 57 |
+
if (!valid) return
|
| 58 |
+
return selected || (root ? nativePickerPath(root) : undefined)
|
| 59 |
+
},
|
| 60 |
+
selection(root: string, path: string) {
|
| 61 |
+
return selectedTreePath(root, path, "directory")
|
| 62 |
+
},
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export function pickerFileSearchQuery(root: string, input: string, home: string) {
|
| 67 |
+
const value = input
|
| 68 |
+
.replace(/\\/g, "/")
|
| 69 |
+
.replace(/^~(?=\/|$)/, home)
|
| 70 |
+
.replace(/\/+$/, "")
|
| 71 |
+
const base = root.replace(/\\/g, "/").replace(/\/+$/, "")
|
| 72 |
+
if (value === base) return ""
|
| 73 |
+
if (value.startsWith(base + "/")) return value.slice(base.length + 1)
|
| 74 |
+
return value
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export function pickerAbsoluteInput(input: string, home: string, current: string) {
|
| 78 |
+
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
|
| 79 |
+
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
|
| 80 |
+
return canonicalPickerPath(absolute)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export function treePathWithin(base: string | undefined, path: string) {
|
| 84 |
+
return pickerRelativePath(base, path) !== undefined
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
export function canonicalPickerPath(path: string) {
|
| 88 |
+
const value = normalizePickerDrive(path)
|
| 89 |
+
const root = pickerRoot(value)
|
| 90 |
+
const parts = value.slice(root.length).split("/")
|
| 91 |
+
const resolved = parts.reduce<string[]>((output, part) => {
|
| 92 |
+
if (!part || part === ".") return output
|
| 93 |
+
if (part === "..") {
|
| 94 |
+
output.pop()
|
| 95 |
+
return output
|
| 96 |
+
}
|
| 97 |
+
output.push(part)
|
| 98 |
+
return output
|
| 99 |
+
}, [])
|
| 100 |
+
return joinPickerPath(root, resolved.join("/"))
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export function pickerRelativePath(base: string | undefined, path: string) {
|
| 104 |
+
if (!base) return
|
| 105 |
+
const rootPath = canonicalPickerPath(base)
|
| 106 |
+
const targetPath = canonicalPickerPath(path)
|
| 107 |
+
const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//")
|
| 108 |
+
const root = insensitive ? rootPath.toLowerCase() : rootPath
|
| 109 |
+
const target = insensitive ? targetPath.toLowerCase() : targetPath
|
| 110 |
+
if (target === root) return ""
|
| 111 |
+
const prefix = root.endsWith("/") ? root : root + "/"
|
| 112 |
+
if (!target.startsWith(prefix)) return
|
| 113 |
+
return targetPath.slice(prefix.length)
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
export function currentPickerSuggestions<T>(result: { query: string; items: readonly T[] } | undefined, query: string) {
|
| 117 |
+
if (result?.query !== query) return []
|
| 118 |
+
return result.items
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export function preloadTreeDirectories(
|
| 122 |
+
parent: string,
|
| 123 |
+
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
|
| 124 |
+
) {
|
| 125 |
+
return treeEntries(
|
| 126 |
+
parent,
|
| 127 |
+
nodes.filter((node) => node.type === "directory"),
|
| 128 |
+
)
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
export function advanceTreePreload(advanced: Set<string>, path: string) {
|
| 132 |
+
if (advanced.has(path)) return false
|
| 133 |
+
advanced.add(path)
|
| 134 |
+
return true
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
export function activeTreeNavigation(request: number, current: number) {
|
| 138 |
+
return request === current
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
export function createPriorityTaskQueue<T>(concurrency: number) {
|
| 142 |
+
type Job = {
|
| 143 |
+
key: string
|
| 144 |
+
priority: "user" | "background"
|
| 145 |
+
promise: Promise<T>
|
| 146 |
+
run: () => void
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const jobs = new Map<string, Job>()
|
| 150 |
+
const user: Job[] = []
|
| 151 |
+
const background: Job[] = []
|
| 152 |
+
let active = 0
|
| 153 |
+
|
| 154 |
+
const drain = () => {
|
| 155 |
+
while (active < concurrency) {
|
| 156 |
+
const job = user.pop() ?? background.shift()
|
| 157 |
+
if (!job) return
|
| 158 |
+
active++
|
| 159 |
+
job.run()
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
const schedule = (key: string, priority: Job["priority"], task: () => Promise<T>) => {
|
| 164 |
+
const existing = jobs.get(key)
|
| 165 |
+
if (existing) {
|
| 166 |
+
if (priority === "user") promote(key)
|
| 167 |
+
return existing.promise
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
const deferred = Promise.withResolvers<T>()
|
| 171 |
+
const job: Job = {
|
| 172 |
+
key,
|
| 173 |
+
priority,
|
| 174 |
+
promise: deferred.promise,
|
| 175 |
+
run: () => {
|
| 176 |
+
const complete = () => {
|
| 177 |
+
active--
|
| 178 |
+
jobs.delete(key)
|
| 179 |
+
drain()
|
| 180 |
+
}
|
| 181 |
+
Promise.resolve()
|
| 182 |
+
.then(task)
|
| 183 |
+
.then(
|
| 184 |
+
(value) => {
|
| 185 |
+
complete()
|
| 186 |
+
deferred.resolve(value)
|
| 187 |
+
},
|
| 188 |
+
(error) => {
|
| 189 |
+
complete()
|
| 190 |
+
deferred.reject(error)
|
| 191 |
+
},
|
| 192 |
+
)
|
| 193 |
+
},
|
| 194 |
+
}
|
| 195 |
+
jobs.set(key, job)
|
| 196 |
+
;(priority === "user" ? user : background).push(job)
|
| 197 |
+
drain()
|
| 198 |
+
return job.promise
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
const promote = (key: string) => {
|
| 202 |
+
const job = jobs.get(key)
|
| 203 |
+
if (!job || job.priority === "user") return
|
| 204 |
+
const index = background.indexOf(job)
|
| 205 |
+
if (index === -1) return
|
| 206 |
+
background.splice(index, 1)
|
| 207 |
+
job.priority = "user"
|
| 208 |
+
user.push(job)
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
return { schedule, promote }
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
|
| 215 |
+
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) {
|
| 219 |
+
if (count === 0) return -1
|
| 220 |
+
return (current + delta + count) % count
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
export function absoluteTreePath(root: string, path: string) {
|
| 224 |
+
const base = trimPickerPath(root)
|
| 225 |
+
const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
|
| 226 |
+
if (!relative) return base || "/"
|
| 227 |
+
if (!base || base === "/") return "/" + relative
|
| 228 |
+
if (base.endsWith("/")) return base + relative
|
| 229 |
+
return `${base}/${relative}`
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) {
|
| 233 |
+
const directory = path.endsWith("/")
|
| 234 |
+
if (mode === "file") {
|
| 235 |
+
if (directory) return
|
| 236 |
+
if (!base) return path
|
| 237 |
+
const absolute = absoluteTreePath(root, path)
|
| 238 |
+
return pickerRelativePath(base, absolute)
|
| 239 |
+
}
|
| 240 |
+
return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
export function nativePickerPath(path: string) {
|
| 244 |
+
const value = trimPickerPath(path)
|
| 245 |
+
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
|
| 246 |
+
return value
|
| 247 |
+
}
|
| 248 |
+
import { getFilename } from "@opencode-ai/core/util/path"
|
| 249 |
+
import fuzzysort from "fuzzysort"
|
| 250 |
+
import { ServerSDK } from "@/context/server-sdk"
|
| 251 |
+
|
| 252 |
+
export function cleanPickerInput(value: string) {
|
| 253 |
+
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
| 254 |
+
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
export function normalizePickerPath(input: string) {
|
| 258 |
+
const value = input.replaceAll("\\", "/")
|
| 259 |
+
if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/")
|
| 260 |
+
return value.replace(/\/+/g, "/")
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
export function normalizePickerDrive(input: string) {
|
| 264 |
+
const value = normalizePickerPath(input)
|
| 265 |
+
if (/^[A-Za-z]:$/.test(value)) return value + "/"
|
| 266 |
+
return value
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
export function trimPickerPath(input: string) {
|
| 270 |
+
const value = normalizePickerDrive(input)
|
| 271 |
+
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
|
| 272 |
+
return value.replace(/\/+$/, "")
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
export function joinPickerPath(base: string | undefined, relative: string) {
|
| 276 |
+
const root = trimPickerPath(base ?? "")
|
| 277 |
+
const path = trimPickerPath(relative).replace(/^\/+/, "")
|
| 278 |
+
if (!root) return path
|
| 279 |
+
if (!path) return root
|
| 280 |
+
if (root.endsWith("/")) return root + path
|
| 281 |
+
return root + "/" + path
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
export function pickerRoot(input: string) {
|
| 285 |
+
const value = normalizePickerDrive(input)
|
| 286 |
+
if (value.startsWith("//")) {
|
| 287 |
+
const [server, share] = value.slice(2).split("/")
|
| 288 |
+
if (server && share) return `//${server}/${share}`
|
| 289 |
+
return "//"
|
| 290 |
+
}
|
| 291 |
+
if (value.startsWith("/")) return "/"
|
| 292 |
+
if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3)
|
| 293 |
+
return ""
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
export function pickerParent(input: string) {
|
| 297 |
+
const value = trimPickerPath(input)
|
| 298 |
+
const root = pickerRoot(value)
|
| 299 |
+
if (value === root) return value
|
| 300 |
+
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
|
| 301 |
+
const index = value.lastIndexOf("/")
|
| 302 |
+
if (index < root.length) return root
|
| 303 |
+
if (index <= 0) return "/"
|
| 304 |
+
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
|
| 305 |
+
return value.slice(0, index)
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function pickerTilde(absolute: string, home: string) {
|
| 309 |
+
const path = trimPickerPath(absolute)
|
| 310 |
+
if (!home) return ""
|
| 311 |
+
const root = trimPickerPath(home)
|
| 312 |
+
if (/^[A-Za-z]:\//.test(root)) return ""
|
| 313 |
+
if (path === root) return "~"
|
| 314 |
+
if (path.startsWith(root + "/")) return "~" + path.slice(root.length)
|
| 315 |
+
return ""
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
export function displayPickerPath(path: string, input: string, home: string) {
|
| 319 |
+
const value = trimPickerPath(path)
|
| 320 |
+
if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\")
|
| 321 |
+
return pickerTilde(value, home) || value
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
|
| 325 |
+
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
| 326 |
+
let current = 0
|
| 327 |
+
|
| 328 |
+
const scoped = (value: string) => {
|
| 329 |
+
const raw = normalizePickerDrive(value)
|
| 330 |
+
const root = pickerRoot(raw)
|
| 331 |
+
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
| 332 |
+
const base = args.base()
|
| 333 |
+
if (!base) return
|
| 334 |
+
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
| 335 |
+
const home = args.home()
|
| 336 |
+
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
| 337 |
+
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
| 338 |
+
return { directory: trimPickerPath(base), path: raw }
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
const directories = async (directory: string) => {
|
| 342 |
+
const key = trimPickerPath(directory)
|
| 343 |
+
const existing = cache.get(key)
|
| 344 |
+
if (existing) return existing
|
| 345 |
+
const request = args.sdk.api.file
|
| 346 |
+
.list({ location: { directory: key } })
|
| 347 |
+
.then((result) => result.data)
|
| 348 |
+
.catch(() => [])
|
| 349 |
+
.then((nodes) =>
|
| 350 |
+
nodes
|
| 351 |
+
.filter((node) => node.type === "directory")
|
| 352 |
+
.map((node) => {
|
| 353 |
+
const relative = trimPickerPath(normalizePickerDrive(node.path))
|
| 354 |
+
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
|
| 355 |
+
}),
|
| 356 |
+
)
|
| 357 |
+
cache.set(key, request)
|
| 358 |
+
return request
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
const match = async (directory: string, query: string, limit: number) => {
|
| 362 |
+
const items = await directories(directory)
|
| 363 |
+
if (!query) return items.slice(0, limit).map((item) => item.absolute)
|
| 364 |
+
return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute)
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
return async (filter: string) => {
|
| 368 |
+
const token = ++current
|
| 369 |
+
const active = () => token === current
|
| 370 |
+
const value = cleanPickerInput(filter)
|
| 371 |
+
const input = scoped(value)
|
| 372 |
+
if (!input) return [] as string[]
|
| 373 |
+
const raw = normalizePickerDrive(value)
|
| 374 |
+
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
| 375 |
+
const query = normalizePickerDrive(input.path)
|
| 376 |
+
if (!pathInput) {
|
| 377 |
+
const results = await args.sdk.api.file
|
| 378 |
+
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
| 379 |
+
.then((result) => result.data.map((entry) => entry.path))
|
| 380 |
+
.catch(() => [])
|
| 381 |
+
if (!active()) return []
|
| 382 |
+
if (results.length) {
|
| 383 |
+
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
| 384 |
+
}
|
| 385 |
+
const fallback = query
|
| 386 |
+
? await match(input.directory, query, 50)
|
| 387 |
+
: (await directories(input.directory)).map((item) => item.absolute)
|
| 388 |
+
if (!active()) return []
|
| 389 |
+
return fallback
|
| 390 |
+
}
|
| 391 |
+
const segments = query.replace(/^\/+/, "").split("/")
|
| 392 |
+
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
|
| 393 |
+
const tail = segments.at(-1) ?? ""
|
| 394 |
+
let paths = [input.directory]
|
| 395 |
+
for (const part of head) {
|
| 396 |
+
if (!active()) return []
|
| 397 |
+
if (part === "..") {
|
| 398 |
+
paths = paths.map(pickerParent)
|
| 399 |
+
continue
|
| 400 |
+
}
|
| 401 |
+
paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12)
|
| 402 |
+
if (!active() || paths.length === 0) return []
|
| 403 |
+
}
|
| 404 |
+
const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat()))
|
| 405 |
+
if (!active()) return []
|
| 406 |
+
const base = raw.startsWith("~") ? trimPickerPath(input.directory) : ""
|
| 407 |
+
if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50)
|
| 408 |
+
const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase())
|
| 409 |
+
if (!target) return matches.slice(0, 50)
|
| 410 |
+
const children = await match(target, "", 30)
|
| 411 |
+
if (!active()) return []
|
| 412 |
+
return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50)
|
| 413 |
+
}
|
| 414 |
+
}
|
packages/app/src/components/directory-picker-policy.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ServerConnection } from "@/context/server"
|
| 2 |
+
import type { Platform } from "@/context/platform"
|
| 3 |
+
|
| 4 |
+
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
|
| 5 |
+
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
|
| 6 |
+
return "server" as const
|
| 7 |
+
}
|
packages/app/src/components/directory-picker.test.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, test } from "bun:test"
|
| 2 |
+
import { directoryPickerKind } from "./directory-picker-policy"
|
| 3 |
+
|
| 4 |
+
const local = {
|
| 5 |
+
type: "sidecar",
|
| 6 |
+
variant: "base",
|
| 7 |
+
http: { url: "http://localhost:4096" },
|
| 8 |
+
} as const
|
| 9 |
+
const remote = {
|
| 10 |
+
type: "ssh",
|
| 11 |
+
host: "example.test",
|
| 12 |
+
http: { url: "http://localhost:4096" },
|
| 13 |
+
} as const
|
| 14 |
+
|
| 15 |
+
describe("directoryPickerKind", () => {
|
| 16 |
+
test("uses the native picker only for local desktop projects", () => {
|
| 17 |
+
expect(directoryPickerKind("desktop", local)).toBe("native")
|
| 18 |
+
expect(directoryPickerKind("desktop", remote)).toBe("server")
|
| 19 |
+
expect(directoryPickerKind("web", local)).toBe("server")
|
| 20 |
+
})
|
| 21 |
+
})
|
packages/app/src/components/directory-picker.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 2 |
+
import { ServerConnection } from "@/context/server"
|
| 3 |
+
import { usePlatform } from "@/context/platform"
|
| 4 |
+
import { useSettings } from "@/context/settings"
|
| 5 |
+
import { lazy } from "solid-js"
|
| 6 |
+
import { DialogSelectDirectory } from "./dialog-select-directory"
|
| 7 |
+
import { directoryPickerKind } from "./directory-picker-policy"
|
| 8 |
+
|
| 9 |
+
const DialogSelectDirectoryV2 = lazy(() =>
|
| 10 |
+
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
type DirectoryPickerInput = {
|
| 14 |
+
server: ServerConnection.Any
|
| 15 |
+
title?: string
|
| 16 |
+
multiple?: boolean
|
| 17 |
+
onSelect: (result: string | string[] | null) => void
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function useDirectoryPicker() {
|
| 21 |
+
const platform = usePlatform()
|
| 22 |
+
const settings = useSettings()
|
| 23 |
+
const dialog = useDialog()
|
| 24 |
+
|
| 25 |
+
return (input: DirectoryPickerInput) => {
|
| 26 |
+
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
|
| 27 |
+
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
|
| 28 |
+
return
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
let selected = false
|
| 32 |
+
const onSelect = (result: string | string[] | null) => {
|
| 33 |
+
selected = result !== null
|
| 34 |
+
input.onSelect(result)
|
| 35 |
+
}
|
| 36 |
+
const cancel = () => {
|
| 37 |
+
if (!selected) input.onSelect(null)
|
| 38 |
+
}
|
| 39 |
+
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
| 40 |
+
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
|
| 41 |
+
return
|
| 42 |
+
}
|
| 43 |
+
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
|
| 44 |
+
}
|
| 45 |
+
}
|
packages/app/src/components/edit-project.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getFilename } from "@opencode-ai/core/util/path"
|
| 2 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 3 |
+
import { useMutation } from "@tanstack/solid-query"
|
| 4 |
+
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
| 5 |
+
import { createMemo } from "solid-js"
|
| 6 |
+
import { createStore } from "solid-js/store"
|
| 7 |
+
import { useGlobal } from "@/context/global"
|
| 8 |
+
import { type LocalProject } from "@/context/layout"
|
| 9 |
+
import { ServerConnection } from "@/context/server"
|
| 10 |
+
|
| 11 |
+
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
| 12 |
+
const dialog = useDialog()
|
| 13 |
+
const global = useGlobal()
|
| 14 |
+
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
| 15 |
+
const folderName = createMemo(() => getFilename(props.project.worktree))
|
| 16 |
+
const defaultName = createMemo(() => props.project.name || folderName())
|
| 17 |
+
const [store, setStore] = createStore({
|
| 18 |
+
name: defaultName(),
|
| 19 |
+
color: props.project.icon?.color,
|
| 20 |
+
iconOverride: props.project.icon?.override,
|
| 21 |
+
startup: props.project.commands?.start ?? "",
|
| 22 |
+
dragOver: false,
|
| 23 |
+
iconHover: false,
|
| 24 |
+
})
|
| 25 |
+
let iconInput: HTMLInputElement | undefined
|
| 26 |
+
|
| 27 |
+
function selectFile(file: File) {
|
| 28 |
+
if (!file.type.startsWith("image/")) return
|
| 29 |
+
const reader = new FileReader()
|
| 30 |
+
reader.onload = (event) => {
|
| 31 |
+
const result = event.target?.result
|
| 32 |
+
if (typeof result !== "string") return
|
| 33 |
+
setStore("iconOverride", result)
|
| 34 |
+
setStore("iconHover", false)
|
| 35 |
+
}
|
| 36 |
+
reader.readAsDataURL(file)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function drop(event: DragEvent) {
|
| 40 |
+
event.preventDefault()
|
| 41 |
+
setStore("dragOver", false)
|
| 42 |
+
const file = event.dataTransfer?.files[0]
|
| 43 |
+
if (file) selectFile(file)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function dragOver(event: DragEvent) {
|
| 47 |
+
event.preventDefault()
|
| 48 |
+
setStore("dragOver", true)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function dragLeave() {
|
| 52 |
+
setStore("dragOver", false)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function inputChange(event: Event) {
|
| 56 |
+
const file = (event.currentTarget as HTMLInputElement).files?.[0]
|
| 57 |
+
if (file) selectFile(file)
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function iconClick() {
|
| 61 |
+
if (store.iconOverride && store.iconHover) {
|
| 62 |
+
setStore("iconOverride", "")
|
| 63 |
+
return
|
| 64 |
+
}
|
| 65 |
+
iconInput?.click()
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const save = useMutation(() => ({
|
| 69 |
+
mutationFn: async () => {
|
| 70 |
+
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
| 71 |
+
const start = store.startup.trim()
|
| 72 |
+
|
| 73 |
+
if (props.project.id && props.project.id !== "global") {
|
| 74 |
+
if ((await serverCtx().sdk.protocol) !== "v1") return
|
| 75 |
+
const project = await serverCtx()
|
| 76 |
+
.sdk.client.project.update({
|
| 77 |
+
projectID: props.project.id,
|
| 78 |
+
directory: props.project.worktree,
|
| 79 |
+
name,
|
| 80 |
+
icon: { color: store.color || "", override: store.iconOverride || "" },
|
| 81 |
+
commands: { start },
|
| 82 |
+
})
|
| 83 |
+
.then((result) => result.data)
|
| 84 |
+
if (!project) return
|
| 85 |
+
// const project = await serverCtx().sdk.api.project.update({
|
| 86 |
+
// projectID: props.project.id,
|
| 87 |
+
// name,
|
| 88 |
+
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
| 89 |
+
// commands: { start },
|
| 90 |
+
// })
|
| 91 |
+
serverCtx().sync.set("project", (items) =>
|
| 92 |
+
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
| 93 |
+
)
|
| 94 |
+
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
| 95 |
+
dialog.close()
|
| 96 |
+
return
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
serverCtx().sync.project.meta(props.project.worktree, {
|
| 100 |
+
name,
|
| 101 |
+
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
| 102 |
+
commands: { start: start || undefined },
|
| 103 |
+
})
|
| 104 |
+
dialog.close()
|
| 105 |
+
},
|
| 106 |
+
}))
|
| 107 |
+
|
| 108 |
+
function submit(event: SubmitEvent) {
|
| 109 |
+
event.preventDefault()
|
| 110 |
+
if (save.isPending) return
|
| 111 |
+
save.mutate()
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
store,
|
| 116 |
+
setStore,
|
| 117 |
+
folderName,
|
| 118 |
+
defaultName,
|
| 119 |
+
save,
|
| 120 |
+
submit,
|
| 121 |
+
drop,
|
| 122 |
+
dragOver,
|
| 123 |
+
dragLeave,
|
| 124 |
+
inputChange,
|
| 125 |
+
iconClick,
|
| 126 |
+
close() {
|
| 127 |
+
dialog.close()
|
| 128 |
+
},
|
| 129 |
+
setIconInput(input: HTMLInputElement) {
|
| 130 |
+
iconInput = input
|
| 131 |
+
},
|
| 132 |
+
}
|
| 133 |
+
}
|
packages/app/src/components/external-link.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ComponentProps, splitProps } from "solid-js"
|
| 2 |
+
|
| 3 |
+
export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
|
| 4 |
+
href: string
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export function ExternalLink(props: ExternalLinkProps) {
|
| 8 |
+
const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<a
|
| 12 |
+
href={local.href}
|
| 13 |
+
class={`text-text-strong underline ${local.class ?? ""}`}
|
| 14 |
+
target={local.target ?? "_blank"}
|
| 15 |
+
rel={local.rel ?? "noopener noreferrer"}
|
| 16 |
+
{...rest}
|
| 17 |
+
>
|
| 18 |
+
{local.children}
|
| 19 |
+
</a>
|
| 20 |
+
)
|
| 21 |
+
}
|
packages/app/src/components/file-tree-v2-model.test.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, test } from "bun:test"
|
| 2 |
+
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
|
| 3 |
+
import type { FileNode } from "@opencode-ai/sdk/v2"
|
| 4 |
+
|
| 5 |
+
describe("buildFileTreeV2Model", () => {
|
| 6 |
+
test("builds a sorted tree and flattens expanded directories", () => {
|
| 7 |
+
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
|
| 8 |
+
|
| 9 |
+
expect(model.total).toBe(8)
|
| 10 |
+
expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([
|
| 11 |
+
["docs", "directory", 0],
|
| 12 |
+
["docs/guide.md", "file", 1],
|
| 13 |
+
["src", "directory", 0],
|
| 14 |
+
["src/lib", "directory", 1],
|
| 15 |
+
["src/lib/a.ts", "file", 2],
|
| 16 |
+
["src/lib/b.ts", "file", 2],
|
| 17 |
+
["src/z.ts", "file", 1],
|
| 18 |
+
["README.md", "file", 0],
|
| 19 |
+
])
|
| 20 |
+
})
|
| 21 |
+
|
| 22 |
+
test("skips children of collapsed directories", () => {
|
| 23 |
+
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
|
| 24 |
+
|
| 25 |
+
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
|
| 26 |
+
"src",
|
| 27 |
+
"src/lib",
|
| 28 |
+
"src/z.ts",
|
| 29 |
+
])
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
test("normalizes duplicate and messy paths", () => {
|
| 33 |
+
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
|
| 34 |
+
const rows = flattenFileTreeV2(model, () => true)
|
| 35 |
+
|
| 36 |
+
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
|
| 37 |
+
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
test("handles deeply nested paths", () => {
|
| 41 |
+
const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
|
| 42 |
+
const model = buildFileTreeV2Model([file])
|
| 43 |
+
|
| 44 |
+
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
|
| 45 |
+
})
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
describe("flattenLiveFileTreeV2", () => {
|
| 49 |
+
test("flattens live children using original paths for nested lookups", () => {
|
| 50 |
+
const nodes: Record<string, FileNode[]> = {
|
| 51 |
+
"": [
|
| 52 |
+
{ name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
|
| 53 |
+
{ name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
|
| 54 |
+
],
|
| 55 |
+
src: [
|
| 56 |
+
{ name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
|
| 57 |
+
{ name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
|
| 58 |
+
],
|
| 59 |
+
"src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
expect(
|
| 63 |
+
flattenLiveFileTreeV2(
|
| 64 |
+
(path) => nodes[path] ?? [],
|
| 65 |
+
(path) => path === "src",
|
| 66 |
+
).map((row) => [row.node.path, row.node.originalPath, row.level]),
|
| 67 |
+
).toEqual([
|
| 68 |
+
["src", "src", 0],
|
| 69 |
+
["src/a.ts", "src/a.ts", 1],
|
| 70 |
+
["src/lib", "src/lib", 1],
|
| 71 |
+
["README.md", "README.md", 0],
|
| 72 |
+
])
|
| 73 |
+
})
|
| 74 |
+
})
|
packages/app/src/components/file-tree-v2-model.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { FileNode } from "@opencode-ai/sdk/v2"
|
| 2 |
+
|
| 3 |
+
export type FileTreeV2Model = {
|
| 4 |
+
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
|
| 5 |
+
total: number
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export type FileTreeV2Node = FileNode & { originalPath: string }
|
| 9 |
+
|
| 10 |
+
export type FileTreeV2Row = {
|
| 11 |
+
node: FileTreeV2Node
|
| 12 |
+
level: number
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function normalizeFileTreeV2Path(value: string) {
|
| 16 |
+
return value
|
| 17 |
+
.replaceAll("\\", "/")
|
| 18 |
+
.replace(/^\/+|\/+$/g, "")
|
| 19 |
+
.replace(/\/{2,}/g, "/")
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model {
|
| 23 |
+
const nodes = new Map<string, FileTreeV2Node>()
|
| 24 |
+
|
| 25 |
+
paths.forEach((value) => {
|
| 26 |
+
const file = normalizeFileTreeV2Path(value)
|
| 27 |
+
if (!file) return
|
| 28 |
+
|
| 29 |
+
const parts = file.split("/")
|
| 30 |
+
parts.forEach((name, index) => {
|
| 31 |
+
const path = parts.slice(0, index + 1).join("/")
|
| 32 |
+
if (nodes.has(path)) return
|
| 33 |
+
nodes.set(path, {
|
| 34 |
+
name,
|
| 35 |
+
path,
|
| 36 |
+
absolute: path,
|
| 37 |
+
type: index === parts.length - 1 ? "file" : "directory",
|
| 38 |
+
ignored: false,
|
| 39 |
+
originalPath: index === parts.length - 1 ? value : path,
|
| 40 |
+
})
|
| 41 |
+
})
|
| 42 |
+
})
|
| 43 |
+
|
| 44 |
+
const children = new Map<string, FileTreeV2Node[]>()
|
| 45 |
+
nodes.forEach((node) => {
|
| 46 |
+
const index = node.path.lastIndexOf("/")
|
| 47 |
+
const parent = index === -1 ? "" : node.path.slice(0, index)
|
| 48 |
+
const list = children.get(parent)
|
| 49 |
+
if (list) list.push(node)
|
| 50 |
+
else children.set(parent, [node])
|
| 51 |
+
})
|
| 52 |
+
children.forEach((nodes) =>
|
| 53 |
+
nodes.sort((a, b) => {
|
| 54 |
+
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
| 55 |
+
return a.name.localeCompare(b.name)
|
| 56 |
+
}),
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
return { children, total: nodes.size }
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) {
|
| 63 |
+
const rows: FileTreeV2Row[] = []
|
| 64 |
+
const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 }))
|
| 65 |
+
|
| 66 |
+
while (stack.length > 0) {
|
| 67 |
+
const row = stack.pop()!
|
| 68 |
+
rows.push(row)
|
| 69 |
+
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
|
| 70 |
+
const children = model.children.get(row.node.path) ?? []
|
| 71 |
+
for (let index = children.length - 1; index >= 0; index--) {
|
| 72 |
+
stack.push({ node: children[index]!, level: row.level + 1 })
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return rows
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
export function flattenLiveFileTreeV2(
|
| 80 |
+
children: (path: string) => readonly FileNode[],
|
| 81 |
+
expanded: (path: string) => boolean,
|
| 82 |
+
) {
|
| 83 |
+
const rows: FileTreeV2Row[] = []
|
| 84 |
+
const stack = children("")
|
| 85 |
+
.toReversed()
|
| 86 |
+
.map((node) => ({ node: toLiveNode(node), level: 0 }))
|
| 87 |
+
|
| 88 |
+
while (stack.length > 0) {
|
| 89 |
+
const row = stack.pop()!
|
| 90 |
+
rows.push(row)
|
| 91 |
+
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
|
| 92 |
+
const nested = children(row.node.originalPath)
|
| 93 |
+
for (let index = nested.length - 1; index >= 0; index--) {
|
| 94 |
+
stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
return rows
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function toLiveNode(node: FileNode): FileTreeV2Node {
|
| 102 |
+
return {
|
| 103 |
+
...node,
|
| 104 |
+
path: normalizeFileTreeV2Path(node.path),
|
| 105 |
+
originalPath: node.path,
|
| 106 |
+
}
|
| 107 |
+
}
|
packages/app/src/components/file-tree-v2.tsx
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useFile } from "@/context/file"
|
| 2 |
+
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
| 3 |
+
import "@opencode-ai/ui/v2/file-tree-v2.css"
|
| 4 |
+
import {
|
| 5 |
+
createEffect,
|
| 6 |
+
createMemo,
|
| 7 |
+
createSignal,
|
| 8 |
+
For,
|
| 9 |
+
Show,
|
| 10 |
+
splitProps,
|
| 11 |
+
type ComponentProps,
|
| 12 |
+
type ParentProps,
|
| 13 |
+
} from "solid-js"
|
| 14 |
+
import { Dynamic } from "solid-js/web"
|
| 15 |
+
import type { FileNode } from "@opencode-ai/sdk/v2"
|
| 16 |
+
import { Icon } from "@opencode-ai/ui/v2/icon"
|
| 17 |
+
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
| 18 |
+
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
| 19 |
+
import {
|
| 20 |
+
buildFileTreeV2Model,
|
| 21 |
+
flattenFileTreeV2,
|
| 22 |
+
flattenLiveFileTreeV2,
|
| 23 |
+
normalizeFileTreeV2Path,
|
| 24 |
+
type FileTreeV2Node,
|
| 25 |
+
} from "@/components/file-tree-v2-model"
|
| 26 |
+
import { virtualScrollElement } from "@/components/virtual-scroll-element"
|
| 27 |
+
|
| 28 |
+
export type { Kind } from "@/components/file-tree"
|
| 29 |
+
|
| 30 |
+
const INDENT_STEP = 16
|
| 31 |
+
|
| 32 |
+
function rowPaddingStart(level: number, type: FileNode["type"]) {
|
| 33 |
+
if (type === "directory") return 8 + level * INDENT_STEP
|
| 34 |
+
if (level === 0) return 8
|
| 35 |
+
return 8 + level * INDENT_STEP - INDENT_STEP
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function guideLineStart(level: number) {
|
| 39 |
+
return rowPaddingStart(level, "directory") + 8
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export const kindLabel = (kind: Kind) => {
|
| 43 |
+
if (kind === "add") return "A"
|
| 44 |
+
if (kind === "del") return "D"
|
| 45 |
+
return "M"
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export const kindChange = (kind: Kind) => {
|
| 49 |
+
if (kind === "add") return "added"
|
| 50 |
+
if (kind === "del") return "deleted"
|
| 51 |
+
return "modified"
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const FileTreeNodeV2 = (
|
| 55 |
+
p: ParentProps &
|
| 56 |
+
ComponentProps<"div"> &
|
| 57 |
+
ComponentProps<"button"> & {
|
| 58 |
+
node: FileNode
|
| 59 |
+
level: number
|
| 60 |
+
active?: string
|
| 61 |
+
draggable: boolean
|
| 62 |
+
kinds?: ReadonlyMap<string, Kind>
|
| 63 |
+
as?: "div" | "button"
|
| 64 |
+
},
|
| 65 |
+
) => {
|
| 66 |
+
const [local, rest] = splitProps(p, [
|
| 67 |
+
"node",
|
| 68 |
+
"level",
|
| 69 |
+
"active",
|
| 70 |
+
"draggable",
|
| 71 |
+
"kinds",
|
| 72 |
+
"as",
|
| 73 |
+
"children",
|
| 74 |
+
"class",
|
| 75 |
+
"classList",
|
| 76 |
+
])
|
| 77 |
+
const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<Dynamic
|
| 81 |
+
component={local.as ?? "div"}
|
| 82 |
+
data-slot="file-tree-v2-row"
|
| 83 |
+
data-path={local.node.path}
|
| 84 |
+
data-selected={local.node.path === local.active ? "" : undefined}
|
| 85 |
+
data-ignored={local.node.ignored ? "" : undefined}
|
| 86 |
+
classList={{
|
| 87 |
+
...local.classList,
|
| 88 |
+
[local.class ?? ""]: !!local.class,
|
| 89 |
+
}}
|
| 90 |
+
style={`padding-inline-start: ${rowPaddingStart(local.level, local.node.type)}px`}
|
| 91 |
+
draggable={local.draggable}
|
| 92 |
+
onDragStart={(event: DragEvent) => {
|
| 93 |
+
if (!local.draggable) return
|
| 94 |
+
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
| 95 |
+
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
| 96 |
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
|
| 97 |
+
withFileDragImage(event)
|
| 98 |
+
}}
|
| 99 |
+
{...rest}
|
| 100 |
+
>
|
| 101 |
+
{local.children}
|
| 102 |
+
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
|
| 103 |
+
<bdi dir="auto">{local.node.name}</bdi>
|
| 104 |
+
</span>
|
| 105 |
+
{(() => {
|
| 106 |
+
const value = kind()
|
| 107 |
+
if (!value || local.node.type !== "file") return null
|
| 108 |
+
return (
|
| 109 |
+
<span data-slot="file-tree-v2-change" data-change={kindChange(value)}>
|
| 110 |
+
{kindLabel(value)}
|
| 111 |
+
</span>
|
| 112 |
+
)
|
| 113 |
+
})()}
|
| 114 |
+
</Dynamic>
|
| 115 |
+
)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function GuideLines(props: { level: number }) {
|
| 119 |
+
return (
|
| 120 |
+
<For each={Array.from({ length: props.level })}>
|
| 121 |
+
{(_, index) => <div data-slot="file-tree-v2-guide" style={`inset-inline-start: ${guideLineStart(index())}px`} />}
|
| 122 |
+
</For>
|
| 123 |
+
)
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
export default function FileTreeV2(props: {
|
| 127 |
+
active?: string
|
| 128 |
+
allowed?: readonly string[]
|
| 129 |
+
kinds?: ReadonlyMap<string, Kind>
|
| 130 |
+
draggable?: boolean
|
| 131 |
+
onFileClick?: (file: FileNode) => void
|
| 132 |
+
onFileDoubleClick?: (file: FileNode) => void
|
| 133 |
+
}) {
|
| 134 |
+
const file = useFile()
|
| 135 |
+
const live = () => props.allowed === undefined
|
| 136 |
+
const draggable = () => props.draggable ?? true
|
| 137 |
+
const active = () => normalizeFileTreeV2Path(props.active ?? "")
|
| 138 |
+
const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
|
| 139 |
+
const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live()
|
| 140 |
+
const rows = createMemo(() => {
|
| 141 |
+
if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded)
|
| 142 |
+
return flattenFileTreeV2(model()!, expanded)
|
| 143 |
+
})
|
| 144 |
+
const [root, setRoot] = createSignal<HTMLDivElement>()
|
| 145 |
+
const [focused, setFocused] = createSignal<string>()
|
| 146 |
+
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
| 147 |
+
get count() {
|
| 148 |
+
return rows().length
|
| 149 |
+
},
|
| 150 |
+
getScrollElement: () => virtualScrollElement(root()),
|
| 151 |
+
initialRect: { width: 0, height: 600 },
|
| 152 |
+
estimateSize: () => 28,
|
| 153 |
+
gap: 2,
|
| 154 |
+
overscan: 10,
|
| 155 |
+
get getItemKey() {
|
| 156 |
+
const current = rows()
|
| 157 |
+
return (index: number) => current[index]?.node.path ?? index
|
| 158 |
+
},
|
| 159 |
+
rangeExtractor: (range) => {
|
| 160 |
+
const indexes = defaultRangeExtractor(range)
|
| 161 |
+
const path = focused()
|
| 162 |
+
const index = path ? rows().findIndex((row) => row.node.path === path) : -1
|
| 163 |
+
if (index < 0 || indexes.includes(index)) return indexes
|
| 164 |
+
return [...indexes, index].sort((a, b) => a - b)
|
| 165 |
+
},
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
createEffect(() => {
|
| 169 |
+
if (!live()) return
|
| 170 |
+
void file.tree.list("")
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
// Only scroll when the active path changes (or first appears in the tree).
|
| 174 |
+
// Do not re-scroll when expand/collapse reshuffles `rows()`.
|
| 175 |
+
let scrolledActive: string | undefined
|
| 176 |
+
createEffect(() => {
|
| 177 |
+
const path = active()
|
| 178 |
+
if (!path) {
|
| 179 |
+
scrolledActive = undefined
|
| 180 |
+
return
|
| 181 |
+
}
|
| 182 |
+
const index = rows().findIndex((row) => row.node.path === path)
|
| 183 |
+
if (index < 0) return
|
| 184 |
+
if (scrolledActive === path) return
|
| 185 |
+
scrolledActive = path
|
| 186 |
+
queueMicrotask(() => {
|
| 187 |
+
const next = rows().findIndex((row) => row.node.path === path)
|
| 188 |
+
if (next < 0) return
|
| 189 |
+
if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return
|
| 190 |
+
virtualizer.scrollToIndex(next, { align: "auto" })
|
| 191 |
+
})
|
| 192 |
+
})
|
| 193 |
+
|
| 194 |
+
const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => {
|
| 195 |
+
action?.({
|
| 196 |
+
...node,
|
| 197 |
+
path: node.originalPath,
|
| 198 |
+
absolute: node.originalPath,
|
| 199 |
+
})
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
const toggleDirectory = (path: string, originalPath: string) => {
|
| 203 |
+
if (expanded(path)) {
|
| 204 |
+
file.tree.collapse(originalPath)
|
| 205 |
+
return
|
| 206 |
+
}
|
| 207 |
+
file.tree.expand(originalPath, live() ? undefined : { list: false })
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
|
| 211 |
+
const virtualItemByKey = createMemo(
|
| 212 |
+
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
| 213 |
+
)
|
| 214 |
+
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
|
| 215 |
+
|
| 216 |
+
return (
|
| 217 |
+
<div
|
| 218 |
+
ref={setRoot}
|
| 219 |
+
data-component="file-tree-v2"
|
| 220 |
+
data-total-rows={live() ? rows().length : model()!.total}
|
| 221 |
+
class="group/file-tree-v2"
|
| 222 |
+
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
| 223 |
+
>
|
| 224 |
+
<For each={virtualRowKeys()}>
|
| 225 |
+
{(key) => (
|
| 226 |
+
<Show when={virtualItemByKey().get(key)}>
|
| 227 |
+
{(item) => (
|
| 228 |
+
<div
|
| 229 |
+
style={{
|
| 230 |
+
position: "absolute",
|
| 231 |
+
top: "0",
|
| 232 |
+
"inset-inline-start": "0",
|
| 233 |
+
width: "100%",
|
| 234 |
+
height: `${item().size}px`,
|
| 235 |
+
transform: `translateY(${item().start}px)`,
|
| 236 |
+
}}
|
| 237 |
+
>
|
| 238 |
+
<Show when={rowByKey().get(key as string)}>
|
| 239 |
+
{(row) => (
|
| 240 |
+
<Show
|
| 241 |
+
when={row().node.type === "directory"}
|
| 242 |
+
fallback={
|
| 243 |
+
<FileTreeNodeV2
|
| 244 |
+
node={row().node}
|
| 245 |
+
level={row().level}
|
| 246 |
+
active={active()}
|
| 247 |
+
draggable={draggable()}
|
| 248 |
+
kinds={props.kinds}
|
| 249 |
+
as="button"
|
| 250 |
+
type="button"
|
| 251 |
+
class="relative"
|
| 252 |
+
onFocus={() => setFocused(row().node.path)}
|
| 253 |
+
onBlur={() => setFocused(undefined)}
|
| 254 |
+
onClick={() => selectFile(row().node, props.onFileClick)}
|
| 255 |
+
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
|
| 256 |
+
>
|
| 257 |
+
<GuideLines level={row().level} />
|
| 258 |
+
<Show when={row().level > 0}>
|
| 259 |
+
<div class="w-4 shrink-0" />
|
| 260 |
+
</Show>
|
| 261 |
+
<span class="filetree-iconpair size-4">
|
| 262 |
+
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
|
| 263 |
+
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
| 264 |
+
</span>
|
| 265 |
+
</FileTreeNodeV2>
|
| 266 |
+
}
|
| 267 |
+
>
|
| 268 |
+
<FileTreeNodeV2
|
| 269 |
+
node={row().node}
|
| 270 |
+
level={row().level}
|
| 271 |
+
active={active()}
|
| 272 |
+
draggable={draggable()}
|
| 273 |
+
kinds={props.kinds}
|
| 274 |
+
as="button"
|
| 275 |
+
type="button"
|
| 276 |
+
class="relative"
|
| 277 |
+
onFocus={() => setFocused(row().node.path)}
|
| 278 |
+
onBlur={() => setFocused(undefined)}
|
| 279 |
+
aria-expanded={expanded(row().node.path)}
|
| 280 |
+
onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
|
| 281 |
+
>
|
| 282 |
+
<GuideLines level={row().level} />
|
| 283 |
+
<div
|
| 284 |
+
data-slot="file-tree-v2-chevron"
|
| 285 |
+
data-expanded={expanded(row().node.path) ? "" : undefined}
|
| 286 |
+
class="size-4 flex items-center justify-center"
|
| 287 |
+
>
|
| 288 |
+
<Icon name="chevron-down" />
|
| 289 |
+
</div>
|
| 290 |
+
</FileTreeNodeV2>
|
| 291 |
+
</Show>
|
| 292 |
+
)}
|
| 293 |
+
</Show>
|
| 294 |
+
</div>
|
| 295 |
+
)}
|
| 296 |
+
</Show>
|
| 297 |
+
)}
|
| 298 |
+
</For>
|
| 299 |
+
</div>
|
| 300 |
+
)
|
| 301 |
+
}
|
packages/app/src/components/file-tree.test.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
| 2 |
+
|
| 3 |
+
let shouldListRoot: typeof import("./file-tree").shouldListRoot
|
| 4 |
+
let shouldListExpanded: typeof import("./file-tree").shouldListExpanded
|
| 5 |
+
let dirsToExpand: typeof import("./file-tree").dirsToExpand
|
| 6 |
+
|
| 7 |
+
beforeAll(async () => {
|
| 8 |
+
mock.module("@solidjs/router", () => ({
|
| 9 |
+
useNavigate: () => () => undefined,
|
| 10 |
+
useParams: () => ({}),
|
| 11 |
+
useLocation: () => ({}),
|
| 12 |
+
useSearchParams: () => [{}, () => undefined],
|
| 13 |
+
}))
|
| 14 |
+
mock.module("@/context/file", () => ({
|
| 15 |
+
useFile: () => ({
|
| 16 |
+
tree: {
|
| 17 |
+
state: () => undefined,
|
| 18 |
+
list: () => Promise.resolve(),
|
| 19 |
+
children: () => [],
|
| 20 |
+
expand: () => {},
|
| 21 |
+
collapse: () => {},
|
| 22 |
+
},
|
| 23 |
+
}),
|
| 24 |
+
}))
|
| 25 |
+
mock.module("@opencode-ai/ui/collapsible", () => ({
|
| 26 |
+
Collapsible: {
|
| 27 |
+
Trigger: (props: { children?: unknown }) => props.children,
|
| 28 |
+
Content: (props: { children?: unknown }) => props.children,
|
| 29 |
+
},
|
| 30 |
+
}))
|
| 31 |
+
mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null }))
|
| 32 |
+
mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null }))
|
| 33 |
+
mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children }))
|
| 34 |
+
const mod = await import("./file-tree")
|
| 35 |
+
shouldListRoot = mod.shouldListRoot
|
| 36 |
+
shouldListExpanded = mod.shouldListExpanded
|
| 37 |
+
dirsToExpand = mod.dirsToExpand
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
describe("file tree fetch discipline", () => {
|
| 41 |
+
test("root lists on mount unless already loaded or loading", () => {
|
| 42 |
+
expect(shouldListRoot({ level: 0 })).toBe(true)
|
| 43 |
+
expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
|
| 44 |
+
expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
|
| 45 |
+
expect(shouldListRoot({ level: 1 })).toBe(false)
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
test("nested dirs list only when expanded and stale", () => {
|
| 49 |
+
expect(shouldListExpanded({ level: 1 })).toBe(false)
|
| 50 |
+
expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false)
|
| 51 |
+
expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true)
|
| 52 |
+
expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false)
|
| 53 |
+
expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false)
|
| 54 |
+
expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false)
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
test("allowed auto-expand picks only collapsed dirs", () => {
|
| 58 |
+
const expanded = new Set<string>()
|
| 59 |
+
const filter = { dirs: new Set(["src", "src/components"]) }
|
| 60 |
+
|
| 61 |
+
const first = dirsToExpand({
|
| 62 |
+
level: 0,
|
| 63 |
+
filter,
|
| 64 |
+
expanded: (dir) => expanded.has(dir),
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
expect(first).toEqual(["src", "src/components"])
|
| 68 |
+
|
| 69 |
+
for (const dir of first) expanded.add(dir)
|
| 70 |
+
|
| 71 |
+
const second = dirsToExpand({
|
| 72 |
+
level: 0,
|
| 73 |
+
filter,
|
| 74 |
+
expanded: (dir) => expanded.has(dir),
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
expect(second).toEqual([])
|
| 78 |
+
expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([])
|
| 79 |
+
})
|
| 80 |
+
})
|
packages/app/src/components/file-tree.tsx
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useFile } from "@/context/file"
|
| 2 |
+
import { encodeFilePath } from "@/context/file/path"
|
| 3 |
+
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
| 4 |
+
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
| 5 |
+
import { Icon } from "@opencode-ai/ui/icon"
|
| 6 |
+
import {
|
| 7 |
+
createEffect,
|
| 8 |
+
createMemo,
|
| 9 |
+
For,
|
| 10 |
+
Match,
|
| 11 |
+
on,
|
| 12 |
+
Show,
|
| 13 |
+
splitProps,
|
| 14 |
+
Switch,
|
| 15 |
+
untrack,
|
| 16 |
+
type ComponentProps,
|
| 17 |
+
type ParentProps,
|
| 18 |
+
} from "solid-js"
|
| 19 |
+
import { Dynamic } from "solid-js/web"
|
| 20 |
+
import type { FileNode } from "@opencode-ai/sdk/v2"
|
| 21 |
+
|
| 22 |
+
const MAX_DEPTH = 128
|
| 23 |
+
|
| 24 |
+
export function pathToFileUrl(filepath: string): string {
|
| 25 |
+
return `file://${encodeFilePath(filepath)}`
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export type Kind = "add" | "del" | "mix"
|
| 29 |
+
|
| 30 |
+
export type Filter = {
|
| 31 |
+
files: Set<string>
|
| 32 |
+
dirs: Set<string>
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
|
| 36 |
+
if (input.level !== 0) return false
|
| 37 |
+
if (input.dir?.loaded) return false
|
| 38 |
+
if (input.dir?.loading) return false
|
| 39 |
+
return true
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function shouldListExpanded(input: {
|
| 43 |
+
level: number
|
| 44 |
+
dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean }
|
| 45 |
+
}) {
|
| 46 |
+
if (input.level === 0) return false
|
| 47 |
+
if (!input.dir?.expanded) return false
|
| 48 |
+
if (input.dir.loaded) return false
|
| 49 |
+
if (input.dir.loading) return false
|
| 50 |
+
return true
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function dirsToExpand(input: {
|
| 54 |
+
level: number
|
| 55 |
+
filter?: { dirs: Set<string> }
|
| 56 |
+
expanded: (dir: string) => boolean
|
| 57 |
+
}) {
|
| 58 |
+
if (input.level !== 0) return []
|
| 59 |
+
if (!input.filter) return []
|
| 60 |
+
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const kindLabel = (kind: Kind) => {
|
| 64 |
+
if (kind === "add") return "A"
|
| 65 |
+
if (kind === "del") return "D"
|
| 66 |
+
return "M"
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const kindTextColor = (kind: Kind) => {
|
| 70 |
+
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
| 71 |
+
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
| 72 |
+
return "color: var(--icon-diff-modified-base)"
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const kindDotColor = (kind: Kind) => {
|
| 76 |
+
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
| 77 |
+
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
| 78 |
+
return "background-color: var(--icon-diff-modified-base)"
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
export const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
| 82 |
+
const kind = kinds?.get(node.path)
|
| 83 |
+
if (!kind) return
|
| 84 |
+
if (!marks?.has(node.path)) return
|
| 85 |
+
return kind
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const buildDragImage = (target: HTMLElement) => {
|
| 89 |
+
const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
|
| 90 |
+
const text = target.querySelector("span")
|
| 91 |
+
if (!icon || !text) return
|
| 92 |
+
|
| 93 |
+
const image = document.createElement("div")
|
| 94 |
+
image.className =
|
| 95 |
+
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
| 96 |
+
image.style.position = "absolute"
|
| 97 |
+
image.style.top = "-1000px"
|
| 98 |
+
image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
| 99 |
+
return image
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
export const withFileDragImage = (event: DragEvent) => {
|
| 103 |
+
const image = buildDragImage(event.currentTarget as HTMLElement)
|
| 104 |
+
if (!image) return
|
| 105 |
+
document.body.appendChild(image)
|
| 106 |
+
event.dataTransfer?.setDragImage(image, 0, 12)
|
| 107 |
+
setTimeout(() => document.body.removeChild(image), 0)
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const FileTreeNode = (
|
| 111 |
+
p: ParentProps &
|
| 112 |
+
ComponentProps<"div"> &
|
| 113 |
+
ComponentProps<"button"> & {
|
| 114 |
+
node: FileNode
|
| 115 |
+
level: number
|
| 116 |
+
active?: string
|
| 117 |
+
nodeClass?: string
|
| 118 |
+
draggable: boolean
|
| 119 |
+
kinds?: ReadonlyMap<string, Kind>
|
| 120 |
+
marks?: Set<string>
|
| 121 |
+
as?: "div" | "button"
|
| 122 |
+
},
|
| 123 |
+
) => {
|
| 124 |
+
const [local, rest] = splitProps(p, [
|
| 125 |
+
"node",
|
| 126 |
+
"level",
|
| 127 |
+
"active",
|
| 128 |
+
"nodeClass",
|
| 129 |
+
"draggable",
|
| 130 |
+
"kinds",
|
| 131 |
+
"marks",
|
| 132 |
+
"as",
|
| 133 |
+
"children",
|
| 134 |
+
"class",
|
| 135 |
+
"classList",
|
| 136 |
+
])
|
| 137 |
+
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
| 138 |
+
const active = () => !!kind() && !local.node.ignored
|
| 139 |
+
const color = () => {
|
| 140 |
+
const value = kind()
|
| 141 |
+
if (!value) return
|
| 142 |
+
return kindTextColor(value)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
return (
|
| 146 |
+
<Dynamic
|
| 147 |
+
component={local.as ?? "div"}
|
| 148 |
+
classList={{
|
| 149 |
+
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-start hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
| 150 |
+
"bg-surface-base-active": local.node.path === local.active,
|
| 151 |
+
...local.classList,
|
| 152 |
+
[local.class ?? ""]: !!local.class,
|
| 153 |
+
[local.nodeClass ?? ""]: !!local.nodeClass,
|
| 154 |
+
}}
|
| 155 |
+
style={`padding-inline-start: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
| 156 |
+
draggable={local.draggable}
|
| 157 |
+
onDragStart={(event: DragEvent) => {
|
| 158 |
+
if (!local.draggable) return
|
| 159 |
+
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
| 160 |
+
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
| 161 |
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
|
| 162 |
+
withFileDragImage(event)
|
| 163 |
+
}}
|
| 164 |
+
{...rest}
|
| 165 |
+
>
|
| 166 |
+
{local.children}
|
| 167 |
+
<span
|
| 168 |
+
classList={{
|
| 169 |
+
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
| 170 |
+
"text-text-weaker": local.node.ignored,
|
| 171 |
+
"text-text-weak": !local.node.ignored && !active(),
|
| 172 |
+
}}
|
| 173 |
+
style={active() ? color() : undefined}
|
| 174 |
+
>
|
| 175 |
+
{local.node.name}
|
| 176 |
+
</span>
|
| 177 |
+
{(() => {
|
| 178 |
+
const value = kind()
|
| 179 |
+
if (!value) return null
|
| 180 |
+
if (local.node.type === "file") {
|
| 181 |
+
return (
|
| 182 |
+
<span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
|
| 183 |
+
{kindLabel(value)}
|
| 184 |
+
</span>
|
| 185 |
+
)
|
| 186 |
+
}
|
| 187 |
+
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
|
| 188 |
+
})()}
|
| 189 |
+
</Dynamic>
|
| 190 |
+
)
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
export default function FileTree(props: {
|
| 194 |
+
path: string
|
| 195 |
+
class?: string
|
| 196 |
+
nodeClass?: string
|
| 197 |
+
active?: string
|
| 198 |
+
level?: number
|
| 199 |
+
allowed?: readonly string[]
|
| 200 |
+
modified?: readonly string[]
|
| 201 |
+
kinds?: ReadonlyMap<string, Kind>
|
| 202 |
+
draggable?: boolean
|
| 203 |
+
onFileClick?: (file: FileNode) => void
|
| 204 |
+
onFileDoubleClick?: (file: FileNode) => void
|
| 205 |
+
|
| 206 |
+
_filter?: Filter
|
| 207 |
+
_marks?: Set<string>
|
| 208 |
+
_deeps?: Map<string, number>
|
| 209 |
+
_kinds?: ReadonlyMap<string, Kind>
|
| 210 |
+
_chain?: readonly string[]
|
| 211 |
+
}) {
|
| 212 |
+
const file = useFile()
|
| 213 |
+
const level = props.level ?? 0
|
| 214 |
+
const draggable = () => props.draggable ?? true
|
| 215 |
+
|
| 216 |
+
const key = (p: string) =>
|
| 217 |
+
file
|
| 218 |
+
.normalize(p)
|
| 219 |
+
.replace(/[\\/]+$/, "")
|
| 220 |
+
.replaceAll("\\", "/")
|
| 221 |
+
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
|
| 222 |
+
|
| 223 |
+
const filter = createMemo(() => {
|
| 224 |
+
if (props._filter) return props._filter
|
| 225 |
+
|
| 226 |
+
const allowed = props.allowed
|
| 227 |
+
if (!allowed) return
|
| 228 |
+
|
| 229 |
+
const files = new Set(allowed)
|
| 230 |
+
const dirs = new Set<string>()
|
| 231 |
+
|
| 232 |
+
for (const item of allowed) {
|
| 233 |
+
const parts = item.split("/")
|
| 234 |
+
const parents = parts.slice(0, -1)
|
| 235 |
+
for (const [idx] of parents.entries()) {
|
| 236 |
+
const dir = parents.slice(0, idx + 1).join("/")
|
| 237 |
+
if (dir) dirs.add(dir)
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
return { files, dirs }
|
| 242 |
+
})
|
| 243 |
+
|
| 244 |
+
const marks = createMemo(() => {
|
| 245 |
+
if (props._marks) return props._marks
|
| 246 |
+
|
| 247 |
+
const out = new Set<string>()
|
| 248 |
+
for (const item of props.modified ?? []) out.add(item)
|
| 249 |
+
for (const item of props.kinds?.keys() ?? []) out.add(item)
|
| 250 |
+
if (out.size === 0) return
|
| 251 |
+
return out
|
| 252 |
+
})
|
| 253 |
+
|
| 254 |
+
const kinds = createMemo(() => {
|
| 255 |
+
if (props._kinds) return props._kinds
|
| 256 |
+
return props.kinds
|
| 257 |
+
})
|
| 258 |
+
|
| 259 |
+
const deeps = createMemo(() => {
|
| 260 |
+
if (props._deeps) return props._deeps
|
| 261 |
+
|
| 262 |
+
const out = new Map<string, number>()
|
| 263 |
+
|
| 264 |
+
const root = props.path
|
| 265 |
+
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
| 266 |
+
|
| 267 |
+
const seen = new Set<string>()
|
| 268 |
+
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
| 269 |
+
|
| 270 |
+
const push = (dir: string, lvl: number) => {
|
| 271 |
+
const id = key(dir)
|
| 272 |
+
if (seen.has(id)) return
|
| 273 |
+
seen.add(id)
|
| 274 |
+
|
| 275 |
+
const kids = file.tree
|
| 276 |
+
.children(dir)
|
| 277 |
+
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
|
| 278 |
+
.map((node) => node.path)
|
| 279 |
+
|
| 280 |
+
stack.push({ dir, lvl, i: 0, kids, max: lvl })
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
push(root, level - 1)
|
| 284 |
+
|
| 285 |
+
while (stack.length > 0) {
|
| 286 |
+
const top = stack[stack.length - 1]!
|
| 287 |
+
|
| 288 |
+
if (top.i < top.kids.length) {
|
| 289 |
+
const next = top.kids[top.i]!
|
| 290 |
+
top.i++
|
| 291 |
+
push(next, top.lvl + 1)
|
| 292 |
+
continue
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
out.set(top.dir, top.max)
|
| 296 |
+
stack.pop()
|
| 297 |
+
|
| 298 |
+
const parent = stack[stack.length - 1]
|
| 299 |
+
if (!parent) continue
|
| 300 |
+
parent.max = Math.max(parent.max, top.max)
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
return out
|
| 304 |
+
})
|
| 305 |
+
|
| 306 |
+
createEffect(() => {
|
| 307 |
+
const current = filter()
|
| 308 |
+
const dirs = dirsToExpand({
|
| 309 |
+
level,
|
| 310 |
+
filter: current,
|
| 311 |
+
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
| 312 |
+
})
|
| 313 |
+
for (const dir of dirs) file.tree.expand(dir)
|
| 314 |
+
})
|
| 315 |
+
|
| 316 |
+
createEffect(
|
| 317 |
+
on(
|
| 318 |
+
() => props.path,
|
| 319 |
+
(path) => {
|
| 320 |
+
const dir = untrack(() => file.tree.state(path))
|
| 321 |
+
if (!shouldListRoot({ level, dir })) return
|
| 322 |
+
void file.tree.list(path)
|
| 323 |
+
},
|
| 324 |
+
{ defer: false },
|
| 325 |
+
),
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
const nodes = createMemo(() => {
|
| 329 |
+
const nodes = file.tree.children(props.path)
|
| 330 |
+
const current = filter()
|
| 331 |
+
if (!current) return nodes
|
| 332 |
+
|
| 333 |
+
const parent = (path: string) => {
|
| 334 |
+
const idx = path.lastIndexOf("/")
|
| 335 |
+
if (idx === -1) return ""
|
| 336 |
+
return path.slice(0, idx)
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
const leaf = (path: string) => {
|
| 340 |
+
const idx = path.lastIndexOf("/")
|
| 341 |
+
return idx === -1 ? path : path.slice(idx + 1)
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
const out = nodes.filter((node) => {
|
| 345 |
+
if (node.type === "file") return current.files.has(node.path)
|
| 346 |
+
return current.dirs.has(node.path)
|
| 347 |
+
})
|
| 348 |
+
|
| 349 |
+
const seen = new Set(out.map((node) => node.path))
|
| 350 |
+
|
| 351 |
+
for (const dir of current.dirs) {
|
| 352 |
+
if (parent(dir) !== props.path) continue
|
| 353 |
+
if (seen.has(dir)) continue
|
| 354 |
+
out.push({
|
| 355 |
+
name: leaf(dir),
|
| 356 |
+
path: dir,
|
| 357 |
+
absolute: dir,
|
| 358 |
+
type: "directory",
|
| 359 |
+
ignored: false,
|
| 360 |
+
})
|
| 361 |
+
seen.add(dir)
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
for (const item of current.files) {
|
| 365 |
+
if (parent(item) !== props.path) continue
|
| 366 |
+
if (seen.has(item)) continue
|
| 367 |
+
out.push({
|
| 368 |
+
name: leaf(item),
|
| 369 |
+
path: item,
|
| 370 |
+
absolute: item,
|
| 371 |
+
type: "file",
|
| 372 |
+
ignored: false,
|
| 373 |
+
})
|
| 374 |
+
seen.add(item)
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
out.sort((a, b) => {
|
| 378 |
+
if (a.type !== b.type) {
|
| 379 |
+
return a.type === "directory" ? -1 : 1
|
| 380 |
+
}
|
| 381 |
+
return a.name.localeCompare(b.name)
|
| 382 |
+
})
|
| 383 |
+
|
| 384 |
+
return out
|
| 385 |
+
})
|
| 386 |
+
|
| 387 |
+
return (
|
| 388 |
+
<div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
| 389 |
+
<For each={nodes()}>
|
| 390 |
+
{(node) => {
|
| 391 |
+
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
| 392 |
+
const deep = () => deeps().get(node.path) ?? -1
|
| 393 |
+
const kind = () => visibleKind(node, kinds(), marks())
|
| 394 |
+
const active = () => !!kind() && !node.ignored
|
| 395 |
+
|
| 396 |
+
return (
|
| 397 |
+
<Switch>
|
| 398 |
+
<Match when={node.type === "directory"}>
|
| 399 |
+
<Collapsible
|
| 400 |
+
variant="ghost"
|
| 401 |
+
class="w-full"
|
| 402 |
+
data-scope="filetree"
|
| 403 |
+
forceMount={false}
|
| 404 |
+
open={expanded()}
|
| 405 |
+
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
| 406 |
+
>
|
| 407 |
+
<Collapsible.Trigger>
|
| 408 |
+
<FileTreeNode
|
| 409 |
+
node={node}
|
| 410 |
+
level={level}
|
| 411 |
+
active={props.active}
|
| 412 |
+
nodeClass={props.nodeClass}
|
| 413 |
+
draggable={draggable()}
|
| 414 |
+
kinds={kinds()}
|
| 415 |
+
marks={marks()}
|
| 416 |
+
>
|
| 417 |
+
<div class="size-4 flex items-center justify-center text-icon-weak">
|
| 418 |
+
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
| 419 |
+
</div>
|
| 420 |
+
</FileTreeNode>
|
| 421 |
+
</Collapsible.Trigger>
|
| 422 |
+
<Collapsible.Content class="relative pt-0.5">
|
| 423 |
+
<div
|
| 424 |
+
classList={{
|
| 425 |
+
"absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
|
| 426 |
+
"group-hover/filetree:opacity-100": expanded() && deep() === level,
|
| 427 |
+
"group-hover/filetree:opacity-50": !(expanded() && deep() === level),
|
| 428 |
+
}}
|
| 429 |
+
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
|
| 430 |
+
/>
|
| 431 |
+
<Show
|
| 432 |
+
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
| 433 |
+
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
| 434 |
+
>
|
| 435 |
+
<FileTree
|
| 436 |
+
path={node.path}
|
| 437 |
+
level={level + 1}
|
| 438 |
+
allowed={props.allowed}
|
| 439 |
+
modified={props.modified}
|
| 440 |
+
kinds={props.kinds}
|
| 441 |
+
active={props.active}
|
| 442 |
+
draggable={props.draggable}
|
| 443 |
+
onFileClick={props.onFileClick}
|
| 444 |
+
onFileDoubleClick={props.onFileDoubleClick}
|
| 445 |
+
_filter={filter()}
|
| 446 |
+
_marks={marks()}
|
| 447 |
+
_deeps={deeps()}
|
| 448 |
+
_kinds={kinds()}
|
| 449 |
+
_chain={chain}
|
| 450 |
+
/>
|
| 451 |
+
</Show>
|
| 452 |
+
</Collapsible.Content>
|
| 453 |
+
</Collapsible>
|
| 454 |
+
</Match>
|
| 455 |
+
<Match when={node.type === "file"}>
|
| 456 |
+
<FileTreeNode
|
| 457 |
+
node={node}
|
| 458 |
+
level={level}
|
| 459 |
+
active={props.active}
|
| 460 |
+
nodeClass={props.nodeClass}
|
| 461 |
+
draggable={draggable()}
|
| 462 |
+
kinds={kinds()}
|
| 463 |
+
marks={marks()}
|
| 464 |
+
as="button"
|
| 465 |
+
type="button"
|
| 466 |
+
onClick={() => props.onFileClick?.(node)}
|
| 467 |
+
onDblClick={() => props.onFileDoubleClick?.(node)}
|
| 468 |
+
>
|
| 469 |
+
<div class="w-4 shrink-0" />
|
| 470 |
+
<Switch>
|
| 471 |
+
<Match when={node.ignored}>
|
| 472 |
+
<FileIcon
|
| 473 |
+
node={node}
|
| 474 |
+
class="size-4 filetree-icon filetree-icon--mono"
|
| 475 |
+
style="color: var(--icon-weak-base)"
|
| 476 |
+
mono
|
| 477 |
+
/>
|
| 478 |
+
</Match>
|
| 479 |
+
<Match when={active()}>
|
| 480 |
+
<FileIcon
|
| 481 |
+
node={node}
|
| 482 |
+
class="size-4 filetree-icon filetree-icon--mono"
|
| 483 |
+
style={kindTextColor(kind()!)}
|
| 484 |
+
mono
|
| 485 |
+
/>
|
| 486 |
+
</Match>
|
| 487 |
+
<Match when={!node.ignored}>
|
| 488 |
+
<span class="filetree-iconpair size-4">
|
| 489 |
+
<FileIcon
|
| 490 |
+
node={node}
|
| 491 |
+
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
|
| 492 |
+
/>
|
| 493 |
+
<FileIcon
|
| 494 |
+
node={node}
|
| 495 |
+
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
|
| 496 |
+
mono
|
| 497 |
+
/>
|
| 498 |
+
</span>
|
| 499 |
+
</Match>
|
| 500 |
+
</Switch>
|
| 501 |
+
</FileTreeNode>
|
| 502 |
+
</Match>
|
| 503 |
+
</Switch>
|
| 504 |
+
)
|
| 505 |
+
}}
|
| 506 |
+
</For>
|
| 507 |
+
</div>
|
| 508 |
+
)
|
| 509 |
+
}
|
packages/app/src/components/help-button.tsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
| 2 |
+
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
| 3 |
+
import { isRTL } from "@kobalte/core/i18n"
|
| 4 |
+
import { createSignal, Show } from "solid-js"
|
| 5 |
+
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
| 6 |
+
import { usePlatform } from "@/context/platform"
|
| 7 |
+
import { useSettings } from "@/context/settings"
|
| 8 |
+
import { useLanguage } from "@/context/language"
|
| 9 |
+
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
| 10 |
+
import homeImage from "@/assets/help/home.png"
|
| 11 |
+
import tabsImage from "@/assets/help/tabs.png"
|
| 12 |
+
|
| 13 |
+
// TODO: wire to changelog / seen-state when available
|
| 14 |
+
const showPopover = () => true
|
| 15 |
+
|
| 16 |
+
// can remove this after the tabs rollout has been out for a while
|
| 17 |
+
export function TabsInfoPopup() {
|
| 18 |
+
const settings = useSettings()
|
| 19 |
+
const platform = usePlatform()
|
| 20 |
+
const language = useLanguage()
|
| 21 |
+
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
| 22 |
+
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
| 23 |
+
const rtl = () => isRTL(language.intl())
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side={rtl() ? "left" : "right"}>
|
| 27 |
+
<Show when={settings.general.shouldDisplayTabsToast()}>
|
| 28 |
+
<div
|
| 29 |
+
class="fixed bottom-5 end-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
| 30 |
+
aria-label={language.t("help.tabs.toast.ariaLabel")}
|
| 31 |
+
>
|
| 32 |
+
<button
|
| 33 |
+
type="button"
|
| 34 |
+
aria-label={language.t("help.tabs.toast.dismiss")}
|
| 35 |
+
class="absolute top-3 end-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
| 36 |
+
onClick={settings.general.dismissTabsToast}
|
| 37 |
+
>
|
| 38 |
+
<svg
|
| 39 |
+
width="16"
|
| 40 |
+
height="16"
|
| 41 |
+
viewBox="0 0 16 16"
|
| 42 |
+
fill="none"
|
| 43 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 44 |
+
aria-hidden="true"
|
| 45 |
+
>
|
| 46 |
+
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
|
| 47 |
+
</svg>
|
| 48 |
+
</button>
|
| 49 |
+
<button
|
| 50 |
+
type="button"
|
| 51 |
+
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-start"
|
| 52 |
+
onClick={() => {
|
| 53 |
+
settings.general.dismissTabsToast()
|
| 54 |
+
setDrawerOpen(true)
|
| 55 |
+
}}
|
| 56 |
+
>
|
| 57 |
+
<video
|
| 58 |
+
src={introducingTabsVideo}
|
| 59 |
+
class="absolute inset-0 h-full w-full object-cover"
|
| 60 |
+
loop
|
| 61 |
+
muted
|
| 62 |
+
autoplay
|
| 63 |
+
playsinline
|
| 64 |
+
aria-hidden="true"
|
| 65 |
+
onContextMenu={(event) => event.preventDefault()}
|
| 66 |
+
/>
|
| 67 |
+
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
| 68 |
+
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
| 69 |
+
{language.t("help.tabs.title")}
|
| 70 |
+
</p>
|
| 71 |
+
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
| 72 |
+
{language.t("help.tabs.description")}
|
| 73 |
+
</p>
|
| 74 |
+
</div>
|
| 75 |
+
</button>
|
| 76 |
+
</div>
|
| 77 |
+
</Show>
|
| 78 |
+
<DrawerContent
|
| 79 |
+
style={
|
| 80 |
+
windows()
|
| 81 |
+
? {
|
| 82 |
+
top: "0",
|
| 83 |
+
bottom: "0",
|
| 84 |
+
"inset-inline-end": "0",
|
| 85 |
+
"inset-inline-start": "auto",
|
| 86 |
+
"max-height": "100vh",
|
| 87 |
+
"max-width": "100vw",
|
| 88 |
+
"border-radius": "0",
|
| 89 |
+
}
|
| 90 |
+
: undefined
|
| 91 |
+
}
|
| 92 |
+
>
|
| 93 |
+
<Show when={windows()}>
|
| 94 |
+
<DrawerClose
|
| 95 |
+
as={IconButtonV2}
|
| 96 |
+
type="button"
|
| 97 |
+
size="small"
|
| 98 |
+
variant="neutral"
|
| 99 |
+
aria-label={language.t("common.close")}
|
| 100 |
+
icon={<IconV2 name="xmark-small" />}
|
| 101 |
+
class="absolute top-[10px] start-[-36px]"
|
| 102 |
+
/>
|
| 103 |
+
</Show>
|
| 104 |
+
<div
|
| 105 |
+
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
|
| 106 |
+
classList={{
|
| 107 |
+
"h-[40px] px-4": windows(),
|
| 108 |
+
"h-[52px] p-4": !windows(),
|
| 109 |
+
}}
|
| 110 |
+
>
|
| 111 |
+
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
| 112 |
+
{language.t("help.tabs.date")}
|
| 113 |
+
</p>
|
| 114 |
+
<Show when={!windows()}>
|
| 115 |
+
<DrawerClose
|
| 116 |
+
as={IconButtonV2}
|
| 117 |
+
type="button"
|
| 118 |
+
size="small"
|
| 119 |
+
variant="ghost-muted"
|
| 120 |
+
aria-label={language.t("common.close")}
|
| 121 |
+
icon={<IconV2 name="xmark-small" />}
|
| 122 |
+
/>
|
| 123 |
+
</Show>
|
| 124 |
+
</div>
|
| 125 |
+
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
| 126 |
+
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
| 127 |
+
{language.t("help.tabs.title")}
|
| 128 |
+
</p>
|
| 129 |
+
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
| 130 |
+
<p>{language.t("help.tabs.introduction")}</p>
|
| 131 |
+
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
| 132 |
+
<p>{language.t("help.tabs.sessions")}</p>
|
| 133 |
+
<p>{language.t("help.tabs.organize")}</p>
|
| 134 |
+
<p>{language.t("help.tabs.home")}</p>
|
| 135 |
+
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
| 136 |
+
<p>{language.t("help.tabs.persistence")}</p>
|
| 137 |
+
<p>{language.t("help.tabs.worktrees")}</p>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</DrawerContent>
|
| 141 |
+
</Drawer>
|
| 142 |
+
)
|
| 143 |
+
}
|
packages/app/src/components/model-tooltip.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Show, type Component, type JSX } from "solid-js"
|
| 2 |
+
import { useLanguage } from "@/context/language"
|
| 3 |
+
|
| 4 |
+
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
|
| 5 |
+
type InputMap = Record<InputKey, boolean>
|
| 6 |
+
|
| 7 |
+
type ModelInfo = {
|
| 8 |
+
id: string
|
| 9 |
+
name: string
|
| 10 |
+
provider: {
|
| 11 |
+
name: string
|
| 12 |
+
}
|
| 13 |
+
capabilities?: {
|
| 14 |
+
reasoning: boolean
|
| 15 |
+
input: InputMap
|
| 16 |
+
}
|
| 17 |
+
modalities?: {
|
| 18 |
+
input: Array<string>
|
| 19 |
+
}
|
| 20 |
+
reasoning?: boolean
|
| 21 |
+
limit: {
|
| 22 |
+
context: number
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
|
| 27 |
+
return (
|
| 28 |
+
<div class="flex min-w-0 items-center gap-4">
|
| 29 |
+
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
|
| 30 |
+
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
|
| 31 |
+
</div>
|
| 32 |
+
)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = (
|
| 36 |
+
props,
|
| 37 |
+
) => {
|
| 38 |
+
const language = useLanguage()
|
| 39 |
+
const sourceName = (model: ModelInfo) => {
|
| 40 |
+
const value = `${model.id} ${model.name}`.toLowerCase()
|
| 41 |
+
|
| 42 |
+
if (/claude|anthropic/.test(value)) return language.t("model.provider.anthropic")
|
| 43 |
+
if (/gpt|o[1-4]|codex|openai/.test(value)) return language.t("model.provider.openai")
|
| 44 |
+
if (/gemini|palm|bard|google/.test(value)) return language.t("model.provider.google")
|
| 45 |
+
if (/grok|xai/.test(value)) return language.t("model.provider.xai")
|
| 46 |
+
if (/llama|meta/.test(value)) return language.t("model.provider.meta")
|
| 47 |
+
|
| 48 |
+
return model.provider.name
|
| 49 |
+
}
|
| 50 |
+
const inputLabel = (value: string) => {
|
| 51 |
+
if (value === "text") return language.t("model.input.text")
|
| 52 |
+
if (value === "image") return language.t("model.input.image")
|
| 53 |
+
if (value === "audio") return language.t("model.input.audio")
|
| 54 |
+
if (value === "video") return language.t("model.input.video")
|
| 55 |
+
if (value === "pdf") return language.t("model.input.pdf")
|
| 56 |
+
return value
|
| 57 |
+
}
|
| 58 |
+
const title = () => {
|
| 59 |
+
const tags: Array<string> = []
|
| 60 |
+
if (props.latest) tags.push(language.t("model.tag.latest"))
|
| 61 |
+
if (props.free) tags.push(language.t("model.tag.free"))
|
| 62 |
+
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
| 63 |
+
return `${sourceName(props.model)} ${props.model.name}${suffix}`
|
| 64 |
+
}
|
| 65 |
+
const name = () => {
|
| 66 |
+
const tags: Array<string> = []
|
| 67 |
+
if (props.latest) tags.push(language.t("model.tag.latest"))
|
| 68 |
+
if (props.free) tags.push(language.t("model.tag.free"))
|
| 69 |
+
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
| 70 |
+
return `${props.model.name}${suffix}`
|
| 71 |
+
}
|
| 72 |
+
const inputs = () => {
|
| 73 |
+
if (props.model.capabilities) {
|
| 74 |
+
const input = props.model.capabilities.input
|
| 75 |
+
const order: Array<InputKey> = ["text", "image", "audio", "video", "pdf"]
|
| 76 |
+
const entries = order.filter((key) => input[key]).map((key) => inputLabel(key))
|
| 77 |
+
return entries.length ? entries.join(", ") : undefined
|
| 78 |
+
}
|
| 79 |
+
const raw = props.model.modalities?.input
|
| 80 |
+
if (!raw) return
|
| 81 |
+
const entries = raw.map((value) => inputLabel(value))
|
| 82 |
+
return entries.length ? entries.join(", ") : undefined
|
| 83 |
+
}
|
| 84 |
+
const reasoning = () => {
|
| 85 |
+
if (props.model.capabilities)
|
| 86 |
+
return props.model.capabilities.reasoning
|
| 87 |
+
? language.t("model.tooltip.reasoning.allowed")
|
| 88 |
+
: language.t("model.tooltip.reasoning.none")
|
| 89 |
+
return props.model.reasoning
|
| 90 |
+
? language.t("model.tooltip.reasoning.allowed")
|
| 91 |
+
: language.t("model.tooltip.reasoning.none")
|
| 92 |
+
}
|
| 93 |
+
const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
|
| 94 |
+
const contextLimit = () => props.model.limit.context.toLocaleString(language.intl())
|
| 95 |
+
|
| 96 |
+
if (props.v2) {
|
| 97 |
+
return (
|
| 98 |
+
<div class="flex w-[180px] flex-col gap-2">
|
| 99 |
+
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
|
| 100 |
+
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
|
| 101 |
+
<Show when={inputs()}>
|
| 102 |
+
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
|
| 103 |
+
</Show>
|
| 104 |
+
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
|
| 105 |
+
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
|
| 106 |
+
</div>
|
| 107 |
+
)
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return (
|
| 111 |
+
<div class="flex flex-col gap-1 py-1">
|
| 112 |
+
<div class="text-13-medium">{title()}</div>
|
| 113 |
+
<Show when={inputs()}>
|
| 114 |
+
{(value) => (
|
| 115 |
+
<div class="text-12-regular text-text-invert-base">
|
| 116 |
+
{language.t("model.tooltip.allows", { inputs: value() })}
|
| 117 |
+
</div>
|
| 118 |
+
)}
|
| 119 |
+
</Show>
|
| 120 |
+
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
|
| 121 |
+
<div class="text-12-regular text-text-invert-base">{context()}</div>
|
| 122 |
+
</div>
|
| 123 |
+
)
|
| 124 |
+
}
|
packages/app/src/components/pierre-tree.test.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import { FileTree, type FileTreeDirectoryHandle } from "@pierre/trees"
|
| 3 |
+
|
| 4 |
+
test("reports directory expansion changes", () => {
|
| 5 |
+
const changes: Array<{ path: string; expanded: boolean }> = []
|
| 6 |
+
const tree = new FileTree({
|
| 7 |
+
paths: ["src/"],
|
| 8 |
+
onExpansionChange: (change) => changes.push(change),
|
| 9 |
+
})
|
| 10 |
+
|
| 11 |
+
const src = tree.getItem("src/")
|
| 12 |
+
if (!src || !src.isDirectory()) throw new Error("Expected src to be a directory")
|
| 13 |
+
const directory = src as FileTreeDirectoryHandle
|
| 14 |
+
|
| 15 |
+
directory.expand()
|
| 16 |
+
directory.collapse()
|
| 17 |
+
|
| 18 |
+
expect(changes).toEqual([
|
| 19 |
+
{ path: "src/", expanded: true },
|
| 20 |
+
{ path: "src/", expanded: false },
|
| 21 |
+
])
|
| 22 |
+
tree.cleanUp()
|
| 23 |
+
})
|
packages/app/src/components/prompt-input-v2.tsx
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
| 2 |
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
| 3 |
+
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
| 4 |
+
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
| 5 |
+
import { Icon } from "@opencode-ai/ui/v2/icon"
|
| 6 |
+
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
| 7 |
+
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
| 8 |
+
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
| 9 |
+
import { createEffect, createMemo, on, Show } from "solid-js"
|
| 10 |
+
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
| 11 |
+
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
| 12 |
+
import type { PromptInputProps } from "@/components/prompt-input/contracts"
|
| 13 |
+
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
|
| 14 |
+
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
|
| 15 |
+
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
|
| 16 |
+
import { createPromptSubmit } from "@/components/prompt-input/submit"
|
| 17 |
+
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
| 18 |
+
import { useComments } from "@/context/comments"
|
| 19 |
+
import { useCommand } from "@/context/command"
|
| 20 |
+
import { useLanguage } from "@/context/language"
|
| 21 |
+
import { useLayout } from "@/context/layout"
|
| 22 |
+
import { usePermission } from "@/context/permission"
|
| 23 |
+
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
| 24 |
+
import { usePlatform } from "@/context/platform"
|
| 25 |
+
import { useSDK } from "@/context/sdk"
|
| 26 |
+
import { useSync } from "@/context/sync"
|
| 27 |
+
import { createSessionTabs } from "@/pages/session/helpers"
|
| 28 |
+
import { showToast } from "@/utils/toast"
|
| 29 |
+
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
|
| 30 |
+
import {
|
| 31 |
+
createPromptInputV2Controller,
|
| 32 |
+
createPromptInputV2State,
|
| 33 |
+
type PromptInputV2Interaction,
|
| 34 |
+
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
|
| 35 |
+
|
| 36 |
+
export type PromptInputV2ComposerProps = {
|
| 37 |
+
class?: string
|
| 38 |
+
controller: PromptInputV2ComposerController
|
| 39 |
+
borderUnderlay?: boolean
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
| 43 |
+
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
| 44 |
+
readonly model: PromptInputProps["controls"]["model"]
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
| 48 |
+
const dialog = useDialog()
|
| 49 |
+
const command = useCommand()
|
| 50 |
+
const language = useLanguage()
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div class="flex flex-col gap-3">
|
| 54 |
+
<PromptInputV2
|
| 55 |
+
controller={props.controller}
|
| 56 |
+
borderUnderlay={props.borderUnderlay}
|
| 57 |
+
class={props.class}
|
| 58 |
+
variantControlVisible={!props.controller.model.loading}
|
| 59 |
+
attachKeybind={command.keybindParts("file.attach")}
|
| 60 |
+
attachShortcut={command.keybind("file.attach")}
|
| 61 |
+
modelControl={
|
| 62 |
+
<PromptInputV2ModelControl
|
| 63 |
+
loading={props.controller.model.loading}
|
| 64 |
+
paid={props.controller.model.paid}
|
| 65 |
+
title={language.t("command.model.choose")}
|
| 66 |
+
keybind={command.keybindParts("model.choose")}
|
| 67 |
+
model={props.controller.model.selection}
|
| 68 |
+
providerID={props.controller.model.selection.current()?.provider?.id}
|
| 69 |
+
modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
|
| 70 |
+
onClose={props.controller.restoreFocus}
|
| 71 |
+
onUnpaidClick={() =>
|
| 72 |
+
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
|
| 73 |
+
}
|
| 74 |
+
/>
|
| 75 |
+
}
|
| 76 |
+
/>
|
| 77 |
+
</div>
|
| 78 |
+
)
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
| 82 |
+
const sdk = useSDK()
|
| 83 |
+
const sync = useSync()
|
| 84 |
+
const files = useFile()
|
| 85 |
+
const layout = useLayout()
|
| 86 |
+
const comments = useComments()
|
| 87 |
+
const dialog = useDialog()
|
| 88 |
+
const command = useCommand()
|
| 89 |
+
const permission = usePermission()
|
| 90 |
+
const language = useLanguage()
|
| 91 |
+
const platform = usePlatform()
|
| 92 |
+
const prompt = props.state ?? usePrompt()
|
| 93 |
+
let editor: HTMLDivElement | undefined
|
| 94 |
+
|
| 95 |
+
const interaction = createPromptInputV2State()
|
| 96 |
+
const mode = () => interaction[0].mode
|
| 97 |
+
const history = props.history ?? createPersistedPromptInputHistory()
|
| 98 |
+
const tabs = () => props.controls.session.tabs
|
| 99 |
+
const activeFileTab = createSessionTabs({
|
| 100 |
+
tabs,
|
| 101 |
+
pathFromTab: files.pathFromTab,
|
| 102 |
+
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
| 103 |
+
}).activeFileTab
|
| 104 |
+
const recent = createMemo(() => {
|
| 105 |
+
const all = tabs().all()
|
| 106 |
+
const active = activeFileTab()
|
| 107 |
+
const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
|
| 108 |
+
return order.reduce<string[]>((result, tab) => {
|
| 109 |
+
const path = files.pathFromTab(tab)
|
| 110 |
+
if (!path || result.includes(path)) return result
|
| 111 |
+
return [...result, path]
|
| 112 |
+
}, [])
|
| 113 |
+
})
|
| 114 |
+
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
| 115 |
+
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
| 116 |
+
const attachments = createMemo(() =>
|
| 117 |
+
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
| 118 |
+
)
|
| 119 |
+
const commentCount = createMemo(() => {
|
| 120 |
+
if (mode() === "shell") return 0
|
| 121 |
+
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
| 122 |
+
})
|
| 123 |
+
const blank = createMemo(() => {
|
| 124 |
+
const text = prompt
|
| 125 |
+
.current()
|
| 126 |
+
.map((part) => ("content" in part ? part.content : ""))
|
| 127 |
+
.join("")
|
| 128 |
+
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
|
| 129 |
+
})
|
| 130 |
+
const stopping = createMemo(() => working() && blank())
|
| 131 |
+
const placeholder = createMemo(() =>
|
| 132 |
+
promptPlaceholder({
|
| 133 |
+
mode: mode(),
|
| 134 |
+
commentCount: commentCount(),
|
| 135 |
+
example: mode() === "shell" ? "git status" : "",
|
| 136 |
+
suggest: false,
|
| 137 |
+
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
| 138 |
+
}),
|
| 139 |
+
)
|
| 140 |
+
const designPlaceholder = () =>
|
| 141 |
+
promptDesignPlaceholder(mode(), placeholder(), (key, params) =>
|
| 142 |
+
language.t(key as Parameters<typeof language.t>[0], params as never),
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
const historyComments = () => {
|
| 146 |
+
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
| 147 |
+
return prompt.context.items().flatMap((item) => {
|
| 148 |
+
const comment = item.comment?.trim()
|
| 149 |
+
if (!comment) return []
|
| 150 |
+
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
|
| 151 |
+
const nextSelection =
|
| 152 |
+
selection ??
|
| 153 |
+
(item.selection
|
| 154 |
+
? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
|
| 155 |
+
: undefined)
|
| 156 |
+
if (!nextSelection) return []
|
| 157 |
+
return [
|
| 158 |
+
{
|
| 159 |
+
id: item.commentID ?? item.key,
|
| 160 |
+
path: item.path,
|
| 161 |
+
selection: { ...nextSelection },
|
| 162 |
+
comment,
|
| 163 |
+
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
|
| 164 |
+
origin: item.commentOrigin,
|
| 165 |
+
preview: item.preview,
|
| 166 |
+
} satisfies PromptHistoryComment,
|
| 167 |
+
]
|
| 168 |
+
})
|
| 169 |
+
}
|
| 170 |
+
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
|
| 171 |
+
comments.replace(
|
| 172 |
+
items.map((item) => ({
|
| 173 |
+
id: item.id,
|
| 174 |
+
file: item.path,
|
| 175 |
+
selection: { ...item.selection },
|
| 176 |
+
comment: item.comment,
|
| 177 |
+
time: item.time,
|
| 178 |
+
})),
|
| 179 |
+
)
|
| 180 |
+
prompt.context.replaceComments(
|
| 181 |
+
items.map((item) => ({
|
| 182 |
+
type: "file",
|
| 183 |
+
path: item.path,
|
| 184 |
+
selection: selectionFromLines(item.selection),
|
| 185 |
+
comment: item.comment,
|
| 186 |
+
commentID: item.id,
|
| 187 |
+
commentOrigin: item.origin,
|
| 188 |
+
preview: item.preview,
|
| 189 |
+
})),
|
| 190 |
+
)
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
const accepting = createMemo(() => {
|
| 194 |
+
const id = props.controls.session.id
|
| 195 |
+
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
| 196 |
+
return permission.isAutoAccepting(id, sdk().directory)
|
| 197 |
+
})
|
| 198 |
+
const submission = createPromptSubmit({
|
| 199 |
+
prompt,
|
| 200 |
+
info,
|
| 201 |
+
imageAttachments: attachments,
|
| 202 |
+
commentCount,
|
| 203 |
+
autoAccept: accepting,
|
| 204 |
+
mode,
|
| 205 |
+
working,
|
| 206 |
+
editor: () => editor,
|
| 207 |
+
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
| 208 |
+
promptLength,
|
| 209 |
+
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
| 210 |
+
resetHistoryNavigation: () => controller.resetHistory(),
|
| 211 |
+
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
| 212 |
+
setPopover: (popover) => {
|
| 213 |
+
if (!popover) controller.dispatch({ type: "popover.close" })
|
| 214 |
+
},
|
| 215 |
+
newSessionWorktree: () => props.newSessionWorktree,
|
| 216 |
+
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
| 217 |
+
shouldQueue: props.shouldQueue,
|
| 218 |
+
onQueue: props.onQueue,
|
| 219 |
+
onAbort: props.onAbort,
|
| 220 |
+
onSubmit: props.onSubmit,
|
| 221 |
+
model: props.controls.model.selection,
|
| 222 |
+
})
|
| 223 |
+
|
| 224 |
+
const referenceDescription = (reference: ReferenceInfo) =>
|
| 225 |
+
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
| 226 |
+
const references = createMemo(() =>
|
| 227 |
+
sync()
|
| 228 |
+
.data.reference.filter((reference) => !reference.hidden)
|
| 229 |
+
.map((reference) => ({
|
| 230 |
+
id: `reference:${reference.name}`,
|
| 231 |
+
kind: "reference" as const,
|
| 232 |
+
label: `@${reference.name}`,
|
| 233 |
+
path: reference.path,
|
| 234 |
+
description: reference.description ?? referenceDescription(reference),
|
| 235 |
+
mention: {
|
| 236 |
+
type: "file" as const,
|
| 237 |
+
path: reference.path,
|
| 238 |
+
content: `@${reference.name}`,
|
| 239 |
+
start: 0,
|
| 240 |
+
end: 0,
|
| 241 |
+
mime: "application/x-directory",
|
| 242 |
+
filename: reference.name,
|
| 243 |
+
},
|
| 244 |
+
})),
|
| 245 |
+
)
|
| 246 |
+
const resources = createMemo(() =>
|
| 247 |
+
Object.values(sync().data.mcp_resource).map((resource) => ({
|
| 248 |
+
id: `resource:${resource.server}:${resource.uri}`,
|
| 249 |
+
kind: "resource" as const,
|
| 250 |
+
label: `@${resource.name}`,
|
| 251 |
+
path: resource.uri,
|
| 252 |
+
description: resource.description,
|
| 253 |
+
mention: {
|
| 254 |
+
type: "file" as const,
|
| 255 |
+
path: resource.uri,
|
| 256 |
+
content: `@${resource.name}`,
|
| 257 |
+
start: 0,
|
| 258 |
+
end: 0,
|
| 259 |
+
mime: resource.mimeType ?? "text/plain",
|
| 260 |
+
filename: resource.name,
|
| 261 |
+
url: resource.uri,
|
| 262 |
+
source: {
|
| 263 |
+
type: "resource" as const,
|
| 264 |
+
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
| 265 |
+
clientName: resource.server,
|
| 266 |
+
uri: resource.uri,
|
| 267 |
+
},
|
| 268 |
+
},
|
| 269 |
+
resource,
|
| 270 |
+
})),
|
| 271 |
+
)
|
| 272 |
+
const context = createMemo<PromptInputV2Suggestion[]>(() => [
|
| 273 |
+
...references(),
|
| 274 |
+
...props.controls.agents.available
|
| 275 |
+
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
| 276 |
+
.map((agent) => ({
|
| 277 |
+
id: `agent:${agent.name}`,
|
| 278 |
+
kind: "agent" as const,
|
| 279 |
+
label: `@${agent.name}`,
|
| 280 |
+
mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
|
| 281 |
+
})),
|
| 282 |
+
...resources(),
|
| 283 |
+
...recent().map((path) => ({
|
| 284 |
+
id: `file:${path}`,
|
| 285 |
+
kind: "file" as const,
|
| 286 |
+
label: path,
|
| 287 |
+
path,
|
| 288 |
+
recent: true,
|
| 289 |
+
mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
|
| 290 |
+
})),
|
| 291 |
+
])
|
| 292 |
+
const slashCommands = createMemo(() => [
|
| 293 |
+
...sync().data.command.map((item) => ({
|
| 294 |
+
id: `custom.${item.name}`,
|
| 295 |
+
trigger: item.name,
|
| 296 |
+
title: item.name,
|
| 297 |
+
description: item.description,
|
| 298 |
+
type: "custom" as const,
|
| 299 |
+
})),
|
| 300 |
+
...command.options
|
| 301 |
+
.filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
|
| 302 |
+
.map((item) => ({
|
| 303 |
+
id: item.id,
|
| 304 |
+
trigger: item.slash!,
|
| 305 |
+
title: item.title,
|
| 306 |
+
description: item.description,
|
| 307 |
+
type: "builtin" as const,
|
| 308 |
+
})),
|
| 309 |
+
])
|
| 310 |
+
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
|
| 311 |
+
slashCommands().map((item) => ({
|
| 312 |
+
id: item.id,
|
| 313 |
+
kind: "command",
|
| 314 |
+
label: `/${item.trigger}`,
|
| 315 |
+
trigger: item.trigger,
|
| 316 |
+
title: item.title,
|
| 317 |
+
description: item.description,
|
| 318 |
+
keybind: command.keybindParts(item.id),
|
| 319 |
+
})),
|
| 320 |
+
)
|
| 321 |
+
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
| 322 |
+
const controller = createPromptInputV2Controller({
|
| 323 |
+
store: () => prompt.capture().store,
|
| 324 |
+
state: interaction,
|
| 325 |
+
identity: () => prompt.capture(),
|
| 326 |
+
history: {
|
| 327 |
+
entries: (mode) =>
|
| 328 |
+
history.entries(mode).map((value) => {
|
| 329 |
+
const entry = normalizePromptHistoryEntry(value)
|
| 330 |
+
return { prompt: entry.prompt, metadata: entry.comments }
|
| 331 |
+
}),
|
| 332 |
+
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
|
| 333 |
+
capture: historyComments,
|
| 334 |
+
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
|
| 335 |
+
},
|
| 336 |
+
commands,
|
| 337 |
+
context,
|
| 338 |
+
searchContextFiles: async (query) =>
|
| 339 |
+
(await files.searchFilesAndDirectories(query)).map((path) => ({
|
| 340 |
+
id: `file:${path}`,
|
| 341 |
+
kind: "file",
|
| 342 |
+
label: path,
|
| 343 |
+
path,
|
| 344 |
+
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
|
| 345 |
+
})),
|
| 346 |
+
onContextRemove(item) {
|
| 347 |
+
if (item?.commentID) comments.remove(item.path, item.commentID)
|
| 348 |
+
},
|
| 349 |
+
openAttachment: (attachment) =>
|
| 350 |
+
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
| 351 |
+
openContext(key) {
|
| 352 |
+
const item = controller.contextItem(key)
|
| 353 |
+
if (item) openComment(item, props, sync, layout, files, comments)
|
| 354 |
+
},
|
| 355 |
+
onEditor(element) {
|
| 356 |
+
editor = element as HTMLDivElement
|
| 357 |
+
props.ref?.(editor)
|
| 358 |
+
},
|
| 359 |
+
onSuggestionSelect(item) {
|
| 360 |
+
if (item.kind !== "command") return
|
| 361 |
+
const selected = slashCommands().find((entry) => entry.id === item.id)
|
| 362 |
+
if (!selected || selected.type === "custom") return
|
| 363 |
+
return () => command.trigger(selected.id, "slash")
|
| 364 |
+
},
|
| 365 |
+
attachments: {
|
| 366 |
+
picker: platform.openAttachmentPickerDialog,
|
| 367 |
+
directory: () => sdk().directory,
|
| 368 |
+
isDialogActive: () => !!dialog.active,
|
| 369 |
+
warn: () =>
|
| 370 |
+
showToast({
|
| 371 |
+
title: language.t("prompt.toast.pasteUnsupported.title"),
|
| 372 |
+
description: language.t("prompt.toast.pasteUnsupported.description"),
|
| 373 |
+
}),
|
| 374 |
+
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
|
| 375 |
+
onError: (error) =>
|
| 376 |
+
showToast({
|
| 377 |
+
variant: "error",
|
| 378 |
+
title: language.t("common.requestFailed"),
|
| 379 |
+
description: error instanceof Error ? error.message : String(error),
|
| 380 |
+
}),
|
| 381 |
+
readClipboardImage: platform.readClipboardImage,
|
| 382 |
+
getPathForFile: platform.getPathForFile,
|
| 383 |
+
store: platform.draftStore?.putBlob,
|
| 384 |
+
},
|
| 385 |
+
view: {
|
| 386 |
+
placeholder: designPlaceholder,
|
| 387 |
+
get agent() {
|
| 388 |
+
return props.controls.agents.visible && props.controls.agents.options.length > 0
|
| 389 |
+
? {
|
| 390 |
+
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
| 391 |
+
current: () => props.controls.agents.current,
|
| 392 |
+
onSelect: (value: string) => props.controls.agents.select(value),
|
| 393 |
+
keybind: () => command.keybindParts("agent.cycle"),
|
| 394 |
+
}
|
| 395 |
+
: undefined
|
| 396 |
+
},
|
| 397 |
+
variant: {
|
| 398 |
+
options: () => variants().map((value) => ({ id: value, label: value })),
|
| 399 |
+
current: () => props.controls.model.selection.variant.current() ?? "default",
|
| 400 |
+
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
|
| 401 |
+
keybind: () => command.keybindParts("model.variant.cycle"),
|
| 402 |
+
},
|
| 403 |
+
submit: {
|
| 404 |
+
stopping,
|
| 405 |
+
working,
|
| 406 |
+
onSubmit: () => void submission.handleSubmit(new Event("submit")),
|
| 407 |
+
onStop: () => void submission.abort(),
|
| 408 |
+
},
|
| 409 |
+
},
|
| 410 |
+
})
|
| 411 |
+
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
| 412 |
+
|
| 413 |
+
command.register("prompt-input", () => [
|
| 414 |
+
{
|
| 415 |
+
id: "file.attach",
|
| 416 |
+
title: language.t("prompt.action.attachFile"),
|
| 417 |
+
category: language.t("command.category.file"),
|
| 418 |
+
keybind: "mod+u",
|
| 419 |
+
disabled: controller.state.mode !== "normal",
|
| 420 |
+
onSelect: () => controller.attach(),
|
| 421 |
+
},
|
| 422 |
+
{
|
| 423 |
+
id: "prompt.mode.shell",
|
| 424 |
+
title: language.t("command.prompt.mode.shell"),
|
| 425 |
+
category: language.t("command.category.session"),
|
| 426 |
+
keybind: "mod+shift+x",
|
| 427 |
+
disabled: controller.state.mode === "shell",
|
| 428 |
+
onSelect: () => controller.dispatch({ type: "mode.shell" }),
|
| 429 |
+
},
|
| 430 |
+
{
|
| 431 |
+
id: "prompt.mode.normal",
|
| 432 |
+
title: language.t("command.prompt.mode.normal"),
|
| 433 |
+
category: language.t("command.category.session"),
|
| 434 |
+
keybind: "mod+shift+e",
|
| 435 |
+
disabled: controller.state.mode === "normal",
|
| 436 |
+
onSelect: () => controller.dispatch({ type: "mode.normal" }),
|
| 437 |
+
},
|
| 438 |
+
])
|
| 439 |
+
|
| 440 |
+
createEffect(
|
| 441 |
+
on(
|
| 442 |
+
() => props.edit?.id,
|
| 443 |
+
(id) => {
|
| 444 |
+
const edit = props.edit
|
| 445 |
+
if (!id || !edit) return
|
| 446 |
+
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
| 447 |
+
edit.context.forEach((item) =>
|
| 448 |
+
prompt.context.add({
|
| 449 |
+
type: item.type,
|
| 450 |
+
path: item.path,
|
| 451 |
+
selection: item.selection,
|
| 452 |
+
comment: item.comment,
|
| 453 |
+
commentID: item.commentID,
|
| 454 |
+
commentOrigin: item.commentOrigin,
|
| 455 |
+
preview: item.preview,
|
| 456 |
+
}),
|
| 457 |
+
)
|
| 458 |
+
controller.dispatch({ type: "mode.normal" })
|
| 459 |
+
controller.resetHistory()
|
| 460 |
+
prompt.set(edit.prompt, promptLength(edit.prompt))
|
| 461 |
+
controller.restoreFocus()
|
| 462 |
+
props.onEditLoaded?.()
|
| 463 |
+
},
|
| 464 |
+
{ defer: true },
|
| 465 |
+
),
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
return controller as PromptInputV2ComposerController
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
function PromptInputV2ModelControl(props: {
|
| 472 |
+
loading: boolean
|
| 473 |
+
paid: boolean
|
| 474 |
+
title: string
|
| 475 |
+
keybind: string[]
|
| 476 |
+
model: PromptInputV2ComposerController["model"]["selection"]
|
| 477 |
+
providerID?: string
|
| 478 |
+
modelName: string
|
| 479 |
+
onClose: () => void
|
| 480 |
+
onUnpaidClick: () => void
|
| 481 |
+
}) {
|
| 482 |
+
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
|
| 483 |
+
const content = () => (
|
| 484 |
+
<>
|
| 485 |
+
<Show when={props.providerID}>
|
| 486 |
+
{(providerID) => (
|
| 487 |
+
<ProviderIcon
|
| 488 |
+
id={providerID()}
|
| 489 |
+
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
| 490 |
+
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
| 491 |
+
/>
|
| 492 |
+
)}
|
| 493 |
+
</Show>
|
| 494 |
+
<span class="truncate leading-4">{props.modelName}</span>
|
| 495 |
+
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
| 496 |
+
<Icon name="chevron-down" />
|
| 497 |
+
</span>
|
| 498 |
+
</>
|
| 499 |
+
)
|
| 500 |
+
return (
|
| 501 |
+
<Show when={!props.loading}>
|
| 502 |
+
<TooltipV2
|
| 503 |
+
placement="top"
|
| 504 |
+
gutter={4}
|
| 505 |
+
value={
|
| 506 |
+
<>
|
| 507 |
+
{props.title}
|
| 508 |
+
<KeybindV2 keys={props.keybind} variant="neutral" />
|
| 509 |
+
</>
|
| 510 |
+
}
|
| 511 |
+
>
|
| 512 |
+
<Show
|
| 513 |
+
when={props.paid}
|
| 514 |
+
fallback={
|
| 515 |
+
<ButtonV2
|
| 516 |
+
data-action="prompt-model"
|
| 517 |
+
data-control-type="dialog"
|
| 518 |
+
variant="ghost-muted"
|
| 519 |
+
size="normal"
|
| 520 |
+
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
| 521 |
+
classList={{ "animate-in fade-in": shouldAnimate() }}
|
| 522 |
+
style={{ height: "28px" }}
|
| 523 |
+
onClick={props.onUnpaidClick}
|
| 524 |
+
>
|
| 525 |
+
{content()}
|
| 526 |
+
</ButtonV2>
|
| 527 |
+
}
|
| 528 |
+
>
|
| 529 |
+
<ModelSelectorPopoverV2
|
| 530 |
+
model={props.model}
|
| 531 |
+
trigger={(triggerProps) => (
|
| 532 |
+
<ButtonV2
|
| 533 |
+
{...triggerProps}
|
| 534 |
+
variant="ghost-muted"
|
| 535 |
+
size="normal"
|
| 536 |
+
style={{ height: "28px" }}
|
| 537 |
+
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
| 538 |
+
classList={{ "animate-in fade-in": shouldAnimate() }}
|
| 539 |
+
data-action="prompt-model"
|
| 540 |
+
data-control-type="popover"
|
| 541 |
+
>
|
| 542 |
+
{content()}
|
| 543 |
+
</ButtonV2>
|
| 544 |
+
)}
|
| 545 |
+
onClose={props.onClose}
|
| 546 |
+
/>
|
| 547 |
+
</Show>
|
| 548 |
+
</TooltipV2>
|
| 549 |
+
</Show>
|
| 550 |
+
)
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
function openComment(
|
| 554 |
+
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
| 555 |
+
props: PromptInputV2ControllerProps,
|
| 556 |
+
sync: ReturnType<typeof useSync>,
|
| 557 |
+
layout: ReturnType<typeof useLayout>,
|
| 558 |
+
files: ReturnType<typeof useFile>,
|
| 559 |
+
comments: ReturnType<typeof useComments>,
|
| 560 |
+
) {
|
| 561 |
+
if (!item.commentID) return
|
| 562 |
+
const focus = { file: item.path, id: item.commentID }
|
| 563 |
+
comments.setActive(focus)
|
| 564 |
+
const queueFocus = (attempts = 6) => {
|
| 565 |
+
requestAnimationFrame(() => {
|
| 566 |
+
comments.setFocus({ ...focus })
|
| 567 |
+
if (attempts <= 0) return
|
| 568 |
+
requestAnimationFrame(() => {
|
| 569 |
+
const current = comments.focus()
|
| 570 |
+
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
|
| 571 |
+
})
|
| 572 |
+
})
|
| 573 |
+
}
|
| 574 |
+
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
|
| 575 |
+
const review =
|
| 576 |
+
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
|
| 577 |
+
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
| 578 |
+
if (review) {
|
| 579 |
+
layout.fileTree.setTab("changes")
|
| 580 |
+
props.controls.session.tabs.setActive("review")
|
| 581 |
+
queueFocus()
|
| 582 |
+
return
|
| 583 |
+
}
|
| 584 |
+
layout.fileTree.setTab("all")
|
| 585 |
+
const tab = files.tab(item.path)
|
| 586 |
+
void props.controls.session.tabs.open(tab)
|
| 587 |
+
props.controls.session.tabs.setActive(tab)
|
| 588 |
+
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
|
| 589 |
+
}
|
packages/app/src/components/prompt-input.stories.tsx
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @ts-nocheck
|
| 2 |
+
import { createStore } from "solid-js/store"
|
| 3 |
+
import type { Todo } from "@opencode-ai/sdk/v2"
|
| 4 |
+
import { createPromptState } from "@/context/prompt"
|
| 5 |
+
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
| 6 |
+
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
| 7 |
+
|
| 8 |
+
function createPromptInputStoryRuntime() {
|
| 9 |
+
const state = createPromptState()
|
| 10 |
+
return {
|
| 11 |
+
state,
|
| 12 |
+
history: createPromptInputHistory(),
|
| 13 |
+
submission: {
|
| 14 |
+
abort() {},
|
| 15 |
+
handleSubmit(event: Event) {
|
| 16 |
+
event.preventDefault()
|
| 17 |
+
state.reset()
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function PromptInputExample() {
|
| 24 |
+
const input = createPromptInputStoryRuntime()
|
| 25 |
+
const [controls, setControls] = createStore({
|
| 26 |
+
agent: "build",
|
| 27 |
+
variant: undefined as string | undefined,
|
| 28 |
+
comments: 0,
|
| 29 |
+
tabs: [] as string[],
|
| 30 |
+
activeTab: undefined as string | undefined,
|
| 31 |
+
reviewOpen: false,
|
| 32 |
+
})
|
| 33 |
+
const storyModel = {
|
| 34 |
+
id: "claude-3-7-sonnet",
|
| 35 |
+
name: "Claude 3.7 Sonnet",
|
| 36 |
+
provider: { id: "anthropic", name: "Anthropic" },
|
| 37 |
+
}
|
| 38 |
+
const model = {
|
| 39 |
+
current: () => storyModel,
|
| 40 |
+
list: () => [storyModel],
|
| 41 |
+
visible: () => true,
|
| 42 |
+
set: () => {},
|
| 43 |
+
variant: {
|
| 44 |
+
list: () => ["fast", "thinking"],
|
| 45 |
+
current: () => controls.variant,
|
| 46 |
+
set: (variant?: string) => setControls("variant", variant),
|
| 47 |
+
},
|
| 48 |
+
}
|
| 49 |
+
const inputControls = {
|
| 50 |
+
agents: {
|
| 51 |
+
available: [{ name: "review", hidden: false, mode: "subagent" }],
|
| 52 |
+
options: ["build", "review", "plan"],
|
| 53 |
+
get current() {
|
| 54 |
+
return controls.agent
|
| 55 |
+
},
|
| 56 |
+
loading: false,
|
| 57 |
+
visible: true,
|
| 58 |
+
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
| 59 |
+
},
|
| 60 |
+
model: {
|
| 61 |
+
selection: model,
|
| 62 |
+
paid: true,
|
| 63 |
+
loading: false,
|
| 64 |
+
},
|
| 65 |
+
session: {
|
| 66 |
+
id: "story-session",
|
| 67 |
+
tabs: {
|
| 68 |
+
active: () => controls.activeTab,
|
| 69 |
+
all: () => controls.tabs,
|
| 70 |
+
open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
|
| 71 |
+
setActive: (tab: string) => setControls("activeTab", tab),
|
| 72 |
+
},
|
| 73 |
+
reviewPanel: {
|
| 74 |
+
opened: () => controls.reviewOpen,
|
| 75 |
+
open: () => setControls("reviewOpen", true),
|
| 76 |
+
},
|
| 77 |
+
},
|
| 78 |
+
}
|
| 79 |
+
const addReviewComment = () => {
|
| 80 |
+
const comment = controls.comments + 1
|
| 81 |
+
setControls("comments", comment)
|
| 82 |
+
input.state.context.add({
|
| 83 |
+
type: "file",
|
| 84 |
+
path: "src/components/prompt-input.tsx",
|
| 85 |
+
selection: {
|
| 86 |
+
startLine: 84 + comment,
|
| 87 |
+
startChar: 0,
|
| 88 |
+
endLine: 84 + comment,
|
| 89 |
+
endChar: 0,
|
| 90 |
+
},
|
| 91 |
+
comment: `Review comment ${comment}`,
|
| 92 |
+
commentID: `review-comment-${comment}`,
|
| 93 |
+
commentOrigin: "review",
|
| 94 |
+
preview: "export const PromptInput = ...",
|
| 95 |
+
})
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
return (
|
| 99 |
+
<div class="flex flex-col gap-3">
|
| 100 |
+
<PromptInput controls={inputControls} {...input} />
|
| 101 |
+
<div>
|
| 102 |
+
<button
|
| 103 |
+
type="button"
|
| 104 |
+
class="rounded-md border border-border-weak-base bg-background-base px-2.5 py-1.5 text-12-medium text-text-base hover:bg-background-stronger"
|
| 105 |
+
onClick={addReviewComment}
|
| 106 |
+
>
|
| 107 |
+
Add review comment
|
| 108 |
+
</button>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
const todos: Todo[] = [
|
| 115 |
+
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
| 116 |
+
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
| 117 |
+
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
| 118 |
+
]
|
| 119 |
+
|
| 120 |
+
function PromptInputWithOpenDock() {
|
| 121 |
+
const input = createPromptInputStoryRuntime()
|
| 122 |
+
const [controls, setControls] = createStore({
|
| 123 |
+
agent: "build",
|
| 124 |
+
activeTab: undefined as string | undefined,
|
| 125 |
+
todoCollapsed: false,
|
| 126 |
+
})
|
| 127 |
+
const inputControls = {
|
| 128 |
+
agents: {
|
| 129 |
+
available: [],
|
| 130 |
+
options: ["build"],
|
| 131 |
+
get current() {
|
| 132 |
+
return controls.agent
|
| 133 |
+
},
|
| 134 |
+
loading: false,
|
| 135 |
+
visible: true,
|
| 136 |
+
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
| 137 |
+
},
|
| 138 |
+
model: {
|
| 139 |
+
selection: {
|
| 140 |
+
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
| 141 |
+
variant: { list: () => [], current: () => undefined, set: () => {} },
|
| 142 |
+
},
|
| 143 |
+
paid: true,
|
| 144 |
+
loading: false,
|
| 145 |
+
},
|
| 146 |
+
session: {
|
| 147 |
+
id: "story-session",
|
| 148 |
+
tabs: {
|
| 149 |
+
active: () => controls.activeTab,
|
| 150 |
+
all: () => [],
|
| 151 |
+
open: () => {},
|
| 152 |
+
setActive: (tab: string) => setControls("activeTab", tab),
|
| 153 |
+
},
|
| 154 |
+
reviewPanel: { opened: () => false, open: () => {} },
|
| 155 |
+
},
|
| 156 |
+
}
|
| 157 |
+
const state = {
|
| 158 |
+
blocked: () => false,
|
| 159 |
+
questionRequest: () => undefined,
|
| 160 |
+
permissionRequest: () => undefined,
|
| 161 |
+
permissionResponding: () => false,
|
| 162 |
+
decide: () => {},
|
| 163 |
+
todos: () => todos,
|
| 164 |
+
dock: () => true,
|
| 165 |
+
closing: () => false,
|
| 166 |
+
opening: () => false,
|
| 167 |
+
}
|
| 168 |
+
return (
|
| 169 |
+
<SessionComposerRegion
|
| 170 |
+
controller={createSessionComposerRegionController({
|
| 171 |
+
state,
|
| 172 |
+
sessionKey: () => "story-session",
|
| 173 |
+
sessionID: () => "story-session",
|
| 174 |
+
prompt: input.state,
|
| 175 |
+
ready: () => true,
|
| 176 |
+
centered: () => false,
|
| 177 |
+
todo: {
|
| 178 |
+
collapsed: () => controls.todoCollapsed,
|
| 179 |
+
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
| 180 |
+
},
|
| 181 |
+
followup: () => undefined,
|
| 182 |
+
revert: () => undefined,
|
| 183 |
+
onResponseSubmit: () => {},
|
| 184 |
+
openParent: () => {},
|
| 185 |
+
setPromptRef: () => {},
|
| 186 |
+
setDockRef: () => {},
|
| 187 |
+
})}
|
| 188 |
+
promptInput={
|
| 189 |
+
<PromptInput
|
| 190 |
+
controls={inputControls}
|
| 191 |
+
{...input}
|
| 192 |
+
ref={() => {}}
|
| 193 |
+
newSessionWorktree=""
|
| 194 |
+
onNewSessionWorktreeReset={() => {}}
|
| 195 |
+
/>
|
| 196 |
+
}
|
| 197 |
+
/>
|
| 198 |
+
)
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
export default {
|
| 202 |
+
title: "App/PromptInput",
|
| 203 |
+
id: "app-prompt-input",
|
| 204 |
+
component: PromptInput,
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
export const Basic = {
|
| 208 |
+
render: () => (
|
| 209 |
+
<div class="pt-10">
|
| 210 |
+
<h1 class="mb-4">Prompt Input</h1>
|
| 211 |
+
<PromptInputExample />
|
| 212 |
+
</div>
|
| 213 |
+
),
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
export const DockAlreadyOpen = {
|
| 217 |
+
render: () => (
|
| 218 |
+
<div class="pt-10">
|
| 219 |
+
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
| 220 |
+
<PromptInputWithOpenDock />
|
| 221 |
+
</div>
|
| 222 |
+
),
|
| 223 |
+
}
|