diff options
| author | Adam <[email protected]> | 2025-12-27 05:16:39 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-27 14:43:42 -0600 |
| commit | 21eba5f987482b4e2e75ab1c564815bd7b0613f4 (patch) | |
| tree | 2d8cad03e54baa29d83e1e835a7ef2e64d3897e4 /packages/app/src | |
| parent | c523ca412747d66e0236865a4fa2481f7d50f64e (diff) | |
| download | opencode-21eba5f987482b4e2e75ab1c564815bd7b0613f4.tar.gz opencode-21eba5f987482b4e2e75ab1c564815bd7b0613f4.zip | |
feat(desktop): permissions
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 61 | ||||
| -rw-r--r-- | packages/app/src/context/local.tsx | 25 | ||||
| -rw-r--r-- | packages/app/src/pages/directory-layout.tsx | 11 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 50 |
4 files changed, 133 insertions, 14 deletions
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index c51901eb2..50c8a9d1c 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -15,6 +15,7 @@ import { type McpStatus, type LspStatus, type VcsInfo, + type Permission, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -44,6 +45,9 @@ type State = { todo: { [sessionID: string]: Todo[] } + permission: { + [sessionID: string]: Permission[] + } mcp: { [name: string]: McpStatus } @@ -78,6 +82,7 @@ function createGlobalSync() { }) const children: Record<string, ReturnType<typeof createStore<State>>> = {} + const permissionListeners: Set<(info: { directory: string; permission: Permission }) => void> = new Set() function child(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { @@ -93,6 +98,7 @@ function createGlobalSync() { session_status: {}, session_diff: {}, todo: {}, + permission: {}, mcp: {}, lsp: [], vcs: undefined, @@ -163,6 +169,15 @@ function createGlobalSync() { mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})), lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])), vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)), + permission: () => + sdk.permission.list().then((x) => { + const grouped: Record<string, typeof x.data> = {} + for (const perm of x.data ?? []) { + grouped[perm.sessionID] = grouped[perm.sessionID] ?? [] + grouped[perm.sessionID]!.push(perm) + } + setStore("permission", grouped) + }), } await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) .then(() => setStore("ready", true)) @@ -313,6 +328,46 @@ function createGlobalSync() { setStore("vcs", { branch: event.properties.branch }) break } + case "permission.updated": { + const permissions = store.permission[event.properties.sessionID] + const isNew = !permissions || !permissions.find((p) => p.id === event.properties.id) + if (!permissions) { + setStore("permission", event.properties.sessionID, [event.properties]) + } else { + const result = Binary.search(permissions, event.properties.id, (p) => p.id) + setStore( + "permission", + event.properties.sessionID, + produce((draft) => { + if (result.found) { + draft[result.index] = event.properties + return + } + draft.push(event.properties) + }), + ) + } + if (isNew) { + for (const listener of permissionListeners) { + listener({ directory, permission: event.properties }) + } + } + break + } + case "permission.replied": { + const permissions = store.permission[event.properties.sessionID] + if (!permissions) break + const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id) + if (!result.found) break + setStore( + "permission", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } } }) @@ -384,6 +439,12 @@ function createGlobalSync() { project: { loadSessions, }, + permission: { + onUpdated(listener: (info: { directory: string; permission: Permission }) => void) { + permissionListeners.add(listener) + return () => permissionListeners.delete(listener) + }, + }, } } diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 600a0e4b1..49217b82b 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -377,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const list = async (path: string) => { - return sdk.client.file.list({ path: path + "/" }).then((x) => { - setStore( - "node", - produce((draft) => { - x.data!.forEach((node) => { - if (node.path in draft) return - draft[node.path] = node - }) - }), - ) - }) + return sdk.client.file + .list({ path: path + "/" }) + .then((x) => { + setStore( + "node", + produce((draft) => { + x.data!.forEach((node) => { + if (node.path in draft) return + draft[node.path] = node + }) + }), + ) + }) + .catch(() => {}) } const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index c909a373d..04f90bdcb 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,6 +1,6 @@ import { createMemo, Show, type ParentProps } from "solid-js" import { useParams } from "@solidjs/router" -import { SDKProvider } from "@/context/sdk" +import { SDKProvider, useSDK } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" @@ -18,8 +18,15 @@ export default function Layout(props: ParentProps) { <SyncProvider> {iife(() => { const sync = useSync() + const sdk = useSDK() return ( - <DataProvider data={sync.data} directory={directory()}> + <DataProvider + data={sync.data} + directory={directory()} + onPermissionRespond={(input) => { + sdk.client.permission.respond(input) + }} + > <LocalProvider>{props.children}</LocalProvider> </DataProvider> ) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5efba6d99..538a3b840 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -117,6 +117,39 @@ export default function Layout(props: ParentProps) { } }) + onMount(() => { + const unsub = globalSync.permission.onUpdated(({ directory, permission }) => { + const currentDir = params.dir ? base64Decode(params.dir) : undefined + const currentSession = params.id + if (directory === currentDir && permission.sessionID === currentSession) return + const [store] = globalSync.child(directory) + const session = store.session.find((s) => s.id === permission.sessionID) + if (directory === currentDir && session?.parentID === currentSession) return + const sessionTitle = session?.title ?? "New session" + const projectName = getFilename(directory) + showToast({ + persistent: true, + icon: "checklist", + title: "Permission required", + description: `${sessionTitle} in ${projectName} needs permission`, + actions: [ + { + label: "Go to session", + onClick: () => { + navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`) + }, + dismissAfter: true, + }, + { + label: "Dismiss", + onClick: "dismiss", + }, + ], + }) + }) + onCleanup(unsub) + }) + function sortSessions(a: Session, b: Session) { const now = Date.now() const oneMinuteAgo = now - 60 * 1000 @@ -454,8 +487,20 @@ export default function Layout(props: ParentProps) { const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const hasPermissions = createMemo(() => { + const store = globalSync.child(props.project.worktree)[0] + const permissions = store.permission?.[props.session.id] ?? [] + if (permissions.length > 0) return true + const childSessions = store.session.filter((s) => s.parentID === props.session.id) + for (const child of childSessions) { + const childPermissions = store.permission?.[child.id] ?? [] + if (childPermissions.length > 0) return true + } + return false + }) const isWorking = createMemo(() => { if (props.session.id === params.id) return false + if (hasPermissions()) return false const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) @@ -486,6 +531,9 @@ export default function Layout(props: ParentProps) { <Match when={isWorking()}> <Spinner class="size-2.5 mr-0.5" /> </Match> + <Match when={hasPermissions()}> + <div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" /> + </Match> <Match when={hasError()}> <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> </Match> @@ -587,7 +635,7 @@ export default function Layout(props: ParentProps) { <DropdownMenu.Portal> <DropdownMenu.Content> <DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}> - <DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel> + <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel> </DropdownMenu.Item> </DropdownMenu.Content> </DropdownMenu.Portal> |
