|
1 | | -import { Component } from "solid-js" |
| 1 | +import { Select } from "@opencode-ai/ui/select" |
| 2 | +import { showToast } from "@opencode-ai/ui/toast" |
| 3 | +import { Component, For, createMemo, type JSX } from "solid-js" |
| 4 | +import { useGlobalSync } from "@/context/global-sync" |
| 5 | + |
| 6 | +type PermissionAction = "allow" | "ask" | "deny" |
| 7 | + |
| 8 | +type PermissionObject = Record<string, PermissionAction> |
| 9 | +type PermissionValue = PermissionAction | PermissionObject | string[] | undefined |
| 10 | +type PermissionMap = Record<string, PermissionValue> |
| 11 | + |
| 12 | +type PermissionItem = { |
| 13 | + id: string |
| 14 | + title: string |
| 15 | + description: string |
| 16 | +} |
| 17 | + |
| 18 | +const ACTIONS: Array<{ value: PermissionAction; label: string }> = [ |
| 19 | + { value: "allow", label: "Allow" }, |
| 20 | + { value: "ask", label: "Ask" }, |
| 21 | + { value: "deny", label: "Deny" }, |
| 22 | +] |
| 23 | + |
| 24 | +const ITEMS: PermissionItem[] = [ |
| 25 | + { id: "read", title: "Read", description: "Reading a file (matches the file path)" }, |
| 26 | + { id: "edit", title: "Edit", description: "Modify files, including edits, writes, patches, and multi-edits" }, |
| 27 | + { id: "glob", title: "Glob", description: "Match files using glob patterns" }, |
| 28 | + { id: "grep", title: "Grep", description: "Search file contents using regular expressions" }, |
| 29 | + { id: "list", title: "List", description: "List files within a directory" }, |
| 30 | + { id: "bash", title: "Bash", description: "Run shell commands" }, |
| 31 | + { id: "task", title: "Task", description: "Launch sub-agents" }, |
| 32 | + { id: "skill", title: "Skill", description: "Load a skill by name" }, |
| 33 | + { id: "lsp", title: "LSP", description: "Run language server queries" }, |
| 34 | + { id: "todoread", title: "Todo Read", description: "Read the todo list" }, |
| 35 | + { id: "todowrite", title: "Todo Write", description: "Update the todo list" }, |
| 36 | + { id: "webfetch", title: "Web Fetch", description: "Fetch content from a URL" }, |
| 37 | + { id: "websearch", title: "Web Search", description: "Search the web" }, |
| 38 | + { id: "codesearch", title: "Code Search", description: "Search code on the web" }, |
| 39 | + { id: "external_directory", title: "External Directory", description: "Access files outside the project directory" }, |
| 40 | + { id: "doom_loop", title: "Doom Loop", description: "Detect repeated tool calls with identical input" }, |
| 41 | +] |
| 42 | + |
| 43 | +const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"]) |
| 44 | + |
| 45 | +function toMap(value: unknown): PermissionMap { |
| 46 | + if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap |
| 47 | + |
| 48 | + const action = getAction(value) |
| 49 | + if (action) return { "*": action } |
| 50 | + |
| 51 | + return {} |
| 52 | +} |
| 53 | + |
| 54 | +function getAction(value: unknown): PermissionAction | undefined { |
| 55 | + if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction |
| 56 | + return |
| 57 | +} |
| 58 | + |
| 59 | +function getRuleDefault(value: unknown): PermissionAction | undefined { |
| 60 | + const action = getAction(value) |
| 61 | + if (action) return action |
| 62 | + |
| 63 | + if (!value || typeof value !== "object" || Array.isArray(value)) return |
| 64 | + |
| 65 | + return getAction((value as Record<string, unknown>)["*"]) |
| 66 | +} |
2 | 67 |
|
3 | 68 | export const SettingsPermissions: Component = () => { |
| 69 | + const globalSync = useGlobalSync() |
| 70 | + |
| 71 | + const permission = createMemo(() => { |
| 72 | + return toMap(globalSync.data.config.permission) |
| 73 | + }) |
| 74 | + |
| 75 | + const actionFor = (id: string): PermissionAction => { |
| 76 | + const value = permission()[id] |
| 77 | + const direct = getRuleDefault(value) |
| 78 | + if (direct) return direct |
| 79 | + |
| 80 | + const wildcard = getRuleDefault(permission()["*"]) |
| 81 | + if (wildcard) return wildcard |
| 82 | + |
| 83 | + return "allow" |
| 84 | + } |
| 85 | + |
| 86 | + const setPermission = async (id: string, action: PermissionAction) => { |
| 87 | + const before = globalSync.data.config.permission |
| 88 | + const map = toMap(before) |
| 89 | + const existing = map[id] |
| 90 | + |
| 91 | + const nextValue = |
| 92 | + existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action |
| 93 | + |
| 94 | + globalSync.set("config", "permission", { ...map, [id]: nextValue }) |
| 95 | + globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => { |
| 96 | + globalSync.set("config", "permission", before) |
| 97 | + const message = err instanceof Error ? err.message : String(err) |
| 98 | + showToast({ title: "Failed to update permissions", description: message }) |
| 99 | + }) |
| 100 | + } |
| 101 | + |
| 102 | + return ( |
| 103 | + <div class="flex flex-col h-full overflow-y-auto no-scrollbar"> |
| 104 | + <div class="sticky top-0 z-10 bg-background-base border-b border-border-weak-base"> |
| 105 | + <div class="flex flex-col gap-1 p-8 max-w-[720px]"> |
| 106 | + <h2 class="text-16-medium text-text-strong">Permissions</h2> |
| 107 | + <p class="text-14-regular text-text-weak">Control what tools the server can use by default.</p> |
| 108 | + </div> |
| 109 | + </div> |
| 110 | + |
| 111 | + <div class="flex flex-col gap-6 p-8 pt-6 max-w-[720px]"> |
| 112 | + <div class="flex flex-col gap-2"> |
| 113 | + <h3 class="text-14-medium text-text-strong">Appearance</h3> |
| 114 | + <div class="border border-border-weak-base rounded-lg overflow-hidden"> |
| 115 | + <For each={ITEMS}> |
| 116 | + {(item) => ( |
| 117 | + <SettingsRow title={item.title} description={item.description}> |
| 118 | + <Select |
| 119 | + options={ACTIONS} |
| 120 | + current={ACTIONS.find((o) => o.value === actionFor(item.id))} |
| 121 | + value={(o) => o.value} |
| 122 | + label={(o) => o.label} |
| 123 | + onSelect={(option) => option && setPermission(item.id, option.value)} |
| 124 | + variant="secondary" |
| 125 | + size="small" |
| 126 | + /> |
| 127 | + </SettingsRow> |
| 128 | + )} |
| 129 | + </For> |
| 130 | + </div> |
| 131 | + </div> |
| 132 | + </div> |
| 133 | + </div> |
| 134 | + ) |
| 135 | +} |
| 136 | + |
| 137 | +interface SettingsRowProps { |
| 138 | + title: string |
| 139 | + description: string |
| 140 | + children: JSX.Element |
| 141 | +} |
| 142 | + |
| 143 | +const SettingsRow: Component<SettingsRowProps> = (props) => { |
4 | 144 | return ( |
5 | | - <div class="flex flex-col h-full overflow-y-auto"> |
6 | | - <div class="flex flex-col gap-6 p-6 max-w-[600px]"> |
7 | | - <h2 class="text-16-medium text-text-strong">Permissions</h2> |
8 | | - <p class="text-14-regular text-text-weak">Permission settings will be configurable here.</p> |
| 145 | + <div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none"> |
| 146 | + <div class="flex flex-col gap-0.5"> |
| 147 | + <span class="text-14-medium text-text-strong">{props.title}</span> |
| 148 | + <span class="text-12-regular text-text-weak">{props.description}</span> |
9 | 149 | </div> |
| 150 | + <div class="flex-shrink-0">{props.children}</div> |
10 | 151 | </div> |
11 | 152 | ) |
12 | 153 | } |
0 commit comments