diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 8a72701fd..b7e11ba9e 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useUpdaterAction } from "../updater-action" import { useSettings } from "@/context/settings" -import { ExternalLink } from "../external-link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" @@ -147,30 +146,6 @@ const AppearanceSection: Component<{ controller: AppearanceSettingsController }> /> - - {language.t("settings.general.row.theme.description")}{" "} - - {language.t("common.learnMore")} - - - } - > - option.id} - label={(option) => option.name} - onSelect={props.controller.theme.select} - /> - - diff --git a/packages/app/src/components/settings-v2/permissions.tsx b/packages/app/src/components/settings-v2/permissions.tsx index 167386e68..61dadf1f3 100644 --- a/packages/app/src/components/settings-v2/permissions.tsx +++ b/packages/app/src/components/settings-v2/permissions.tsx @@ -1,6 +1,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { showToast } from "@/utils/toast" import { createMemo, createSignal, For, Show, type Component } from "solid-js" import { useLanguage } from "@/context/language" @@ -21,6 +22,8 @@ const DEFAULT_TIERS: TrustTier[] = [ ] const DEFAULT_CONFIG: ProviderPermissionsConfig = { defaultTier: "unassigned", tiers: DEFAULT_TIERS, assignments: {} } +const EFFECTS: Effect[] = ["allow", "deny", "ask"] +const EFFECT_LABEL: Record = { allow: "Allow", deny: "Deny", ask: "Ask" } const ACTION_GROUPS = ["read", "write", "execute", "network"] as const type ActionGroup = (typeof ACTION_GROUPS)[number] const GROUP_LABEL: Record = { read: "Read", write: "Write", execute: "Execute", network: "Network" } @@ -162,10 +165,26 @@ export const SettingsPermissionsV2: Component = () => { } const addDirectoryRule = (tierId: string) => { - const pattern = `src/private/**` + const tier = tiers().find((t) => t.id === tierId) + if (!tier) return + let pattern = "folder/**" + for (let n = 2; tier.directories[pattern]; n++) pattern = `folder-${n}/**` + updateTier(tierId, (t) => ({ + ...t, + directories: { ...t.directories, [pattern]: { read: "deny", write: "deny", execute: "deny", network: "deny" } }, + })) + setEditingPattern(`${tierId}:${pattern}`) + setPatternValue(pattern) + } + + const renameDirectoryRule = (tierId: string, from: string, to: string) => { + const next = to.trim() + if (!next || next === from || next === "**") return updateTier(tierId, (t) => { - if (t.directories[pattern]) return t - return { ...t, directories: { ...t.directories, [pattern]: { read: "deny", write: "deny", execute: "deny", network: "deny" } } } + if (!t.directories[from] || t.directories[next]) return t + const directories: Record = {} + for (const [k, v] of Object.entries(t.directories)) directories[k === from ? next : k] = v + return { ...t, directories } }) } @@ -180,18 +199,23 @@ export const SettingsPermissionsV2: Component = () => { const [editingLabel, setEditingLabel] = createSignal(null) const [editValue, setEditValue] = createSignal("") + // exception pattern being edited, keyed `${tierId}:${pattern}` + const [editingPattern, setEditingPattern] = createSignal(null) + const [patternValue, setPatternValue] = createSignal("") return ( -
-
-

{language.t("settings.permissions.title") ?? "Permissions"}

- - {language.t("settings.permissions.action.addTier") ?? "Add tier"} - + <> +
+
+

{language.t("settings.permissions.title") ?? "Permissions"}

+ + {language.t("settings.permissions.action.addTier") ?? "Add tier"} + +
+

+ {language.t("settings.permissions.description") ?? "Trust tiers control which directories and actions each model can access. Assigned models inherit their tier's matrix; unassigned models use Unassigned."} +

-

- {language.t("settings.permissions.description") ?? "Trust tiers control which directories and actions each model can access. Assigned models inherit their tier's matrix; unassigned models use Unassigned."} -

@@ -199,6 +223,91 @@ export const SettingsPermissionsV2: Component = () => { const summary = () => tierSummary(tier) const isUnassigned = () => tier.id === "unassigned" const assignedModels = () => modelsByTier().get(tier.id) ?? [] + const exceptions = () => Object.entries(tier.directories).filter(([p]) => p !== "**") + + const renderRule = (t: TrustTier, pattern: string, perms: DirectoryPermissions) => { + const isDefault = pattern === "**" + const editKey = `${t.id}:${pattern}` + const commitPattern = () => { + renameDirectoryRule(t.id, pattern, patternValue()) + setEditingPattern(null) + } + return ( +
+ { setEditingPattern(editKey); setPatternValue(pattern) }} + > + {pattern} + + } + > + setPatternValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commitPattern() + if (e.key === "Escape") setEditingPattern(null) + }} + onBlur={commitPattern} + // eslint-disable-next-line jsx-a11y/no-autofocus + autofocus + /> + + } + > + + Everywhere + + + {(group) => { + const effect = () => perms[group] + const isDanger = () => (group === "execute" || group === "network") && effect() === "allow" + return ( +
+ + {GROUP_LABEL[group]} + + EFFECT_LABEL[o]} + valueClass={isDanger() ? "settings-v2-permissions-effect--danger" : undefined} + onSelect={(o) => o && updateDirectoryEffect(t.id, pattern, group, o)} + /> +
+ ) + }}
+ +
+ ) + } return (
@@ -208,14 +317,22 @@ export const SettingsPermissionsV2: Component = () => { fallback={ <>

{tier.label}

- - ⚠️ + {summary()} - { setEditingLabel(tier.id); setEditValue(tier.label) }}> - {language.t("common.rename") ?? "Rename"} - + { setEditingLabel(tier.id); setEditValue(tier.label) }} + /> } @@ -243,10 +360,22 @@ export const SettingsPermissionsV2: Component = () => {
- moveTier(tier.id, -1)}> + moveTier(tier.id, -1)} + > ↑ - moveTier(tier.id, 1)}> + moveTier(tier.id, 1)} + > ↓ @@ -257,9 +386,9 @@ export const SettingsPermissionsV2: Component = () => {
- {/* Directory × Action Matrix */} + {/* Default permissions ("Everywhere" = the ** rule) + folder exceptions */}
-
+ - {([pattern, perms]) => ( -
- {pattern} - {(group) => { - const effect = () => perms[group] - const isDanger = () => (group === "execute" || group === "network") && effect() === "allow" - return ( - - ) - }} - removeDirectoryRule(tier.id, pattern)}> - × - -
- )}
- addDirectoryRule(tier.id)}> - + Add directory rule + {(perms) => renderRule(tier, "**", perms())} + 0}> +
Folder exceptions
+ {([pattern, perms]) => renderRule(tier, pattern, perms)} +
+ addDirectoryRule(tier.id)}> + Add folder exception -

Glob patterns supported, e.g. ~/secrets/**, src/private/**. Most-specific pattern wins.

+

+ Exceptions override the defaults for matching folders. Glob patterns, e.g. ~/secrets/**, src/private/**. Most-specific pattern wins. +

{/* Model Assignment — multi-select picker inside tier card */} @@ -305,7 +418,7 @@ export const SettingsPermissionsV2: Component = () => { {(mid) => ( {mid} - + )}
@@ -345,6 +458,6 @@ export const SettingsPermissionsV2: Component = () => { }}
-
+ ) } diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index f6f1528ec..a8e3c29bb 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -728,194 +728,377 @@ color: var(--v2-state-fg-danger); } -/* Permissions tab */ +/* Permissions tab + Geometry/colour via the design-system tokens (design-polish.css). The + directory × action matrix is responsive: the tier card is an inline-size + container; the DEFAULT layout is stacked (pattern line, then a 2×2 of + labelled selects) because the settings dialog gives a tier card ~390px at + a normal window, and the table layout only switches on once the card is + wide enough to hold five columns. */ +.settings-v2-tab-header.settings-v2-permissions-header { + gap: var(--space-4); + padding-bottom: var(--space-6); +} .settings-v2-permissions-intro { - font-size: 13px; - font-weight: 440; + font-size: var(--font-size-md); + font-weight: 400; line-height: 18px; color: var(--v2-text-text-muted); margin: 0; } .settings-v2-permissions { - gap: 20px; + gap: var(--space-5); + min-width: 0; } .settings-v2-permissions-card { - border-radius: 8px; + container-type: inline-size; + min-width: 0; + border-radius: var(--radius-lg); background-color: var(--v2-background-bg-layer-01); - padding: 16px 20px; - box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); + padding: var(--space-4); + box-shadow: inset 0 0 0 var(--border-width) var(--v2-border-border-muted); display: flex; flex-direction: column; - gap: 16px; + gap: var(--space-4); } .settings-v2-permissions-card-header { display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: var(--space-3); } .settings-v2-permissions-card-title-row { display: flex; align-items: center; - gap: 8px; - flex: 1; + gap: var(--space-2); + flex: 1 1 auto; min-width: 0; } .settings-v2-permissions-card-title { - font-size: 14px; - font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.settings-v2-permissions-card-title { + font-size: var(--font-size-base, 14px); + font-weight: var(--font-weight-emphasis); color: var(--v2-text-text-base); margin: 0; } .settings-v2-permissions-badge { - font-size: 11px; + font-size: var(--font-size-xs); + flex-shrink: 0; + white-space: nowrap; } .settings-v2-permissions-card-actions { display: flex; - gap: 4px; + gap: var(--space-1); flex-shrink: 0; + margin-left: auto; } + +/* ── directory × action matrix ── + Stacked (default): each directory rule is a small settings list — the + pattern as its heading with the remove control, then one row per action + in the General-tab idiom (label left, inline select right). Table mode + (container ≥ 540px): header row + one grid row per pattern. */ .settings-v2-permissions-matrix { display: flex; flex-direction: column; - gap: 8px; -} -.settings-v2-permissions-matrix-header, -.settings-v2-permissions-matrix-row { - display: grid; - grid-template-columns: 1fr 90px 90px 90px 90px 32px; - gap: 8px; - align-items: center; + gap: var(--space-3); + min-width: 0; } .settings-v2-permissions-matrix-header { - font-size: 11px; - font-weight: 600; + display: none; /* table mode only — see the container query below */ + font-size: var(--font-size-xs); + font-weight: var(--font-weight-emphasis); color: var(--v2-text-text-faint); text-transform: uppercase; letter-spacing: 0.05em; } -.settings-v2-permissions-matrix-corner { - font-size: 11px; +.settings-v2-permissions-matrix-head { + text-align: right; +} +.settings-v2-permissions-rule { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "pattern remove" + "read read" + "write write" + "execute execute" + "network network"; + column-gap: var(--space-2); + row-gap: 0; + align-items: center; + min-width: 0; + border-radius: var(--radius-md); + border: var(--border-width) solid var(--v2-border-border-muted); + padding: var(--space-1) var(--space-3); } .settings-v2-permissions-matrix-pattern { - font-size: 13px; + grid-area: pattern; + font-size: var(--font-size-md); font-family: var(--font-mono, monospace); color: var(--v2-text-text-base); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; + padding-block: var(--space-2); + justify-self: start; + max-width: 100%; } -.settings-v2-permissions-matrix-head { - text-align: center; +/* exception patterns are click-to-edit */ +button.settings-v2-permissions-matrix-pattern { + border: 0; + background: transparent; + padding-inline: 0; + cursor: text; + text-align: left; + border-radius: var(--radius-sm); + text-decoration: underline dotted transparent; + text-underline-offset: 3px; + transition: text-decoration-color 0.16s ease; } -.settings-v2-permissions-select { - width: 100%; - padding: 4px 6px; - border-radius: 6px; - border: 0.5px solid var(--v2-border-border-base); - background: var(--v2-background-bg-base); - font-size: 12px; +button.settings-v2-permissions-matrix-pattern:hover { + text-decoration-color: var(--v2-text-text-muted); +} +button.settings-v2-permissions-matrix-pattern:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; +} +.settings-v2-permissions-pattern-input { + grid-area: pattern; + font-family: var(--font-mono, monospace); + min-width: 0; +} +/* the ** rule reads as a plain label, not a glob */ +.settings-v2-permissions-matrix-pattern--default { + font-family: inherit; + font-weight: var(--font-weight-emphasis); +} +.settings-v2-permissions-group-label { + margin: var(--space-2) 0 0; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-emphasis); + color: var(--v2-text-text-faint); + text-transform: uppercase; + letter-spacing: 0.05em; +} +/* the default rule sits directly in the card — no box, its rows are the + card's own rows; exceptions are boxed so they read as overrides */ +.settings-v2-permissions-rule[data-default] { + border: 0; + padding-inline: 0; +} +.settings-v2-permissions-rule[data-default] .settings-v2-permissions-matrix-remove { + display: none; +} +.settings-v2-permissions-matrix-remove { + grid-area: remove; +} +.settings-v2-permissions-matrix-cell { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-width: 0; + padding-block: var(--space-1); + border-top: var(--border-width) solid var(--v2-border-border-muted); +} +.settings-v2-permissions-matrix-cell[data-group="read"] { + grid-area: read; +} +.settings-v2-permissions-matrix-cell[data-group="write"] { + grid-area: write; +} +.settings-v2-permissions-matrix-cell[data-group="execute"] { + grid-area: execute; +} +.settings-v2-permissions-matrix-cell[data-group="network"] { + grid-area: network; +} +.settings-v2-permissions-matrix-cell-label { + font-size: var(--font-size-md); + font-weight: 500; color: var(--v2-text-text-base); + cursor: default; +} +/* execute / network = allow is the risky cell: value reads in the danger + colour, and a small marker so colour isn't the only signal */ +/* outranks select-v2's own value-text rule (lazy-loaded after this file) */ +[data-component="select-v2"][data-appearance] + [data-slot="select-v2-value-text"].settings-v2-permissions-effect--danger { + color: var(--v2-state-fg-danger); + text-decoration: underline dotted; + text-underline-offset: 3px; } -.settings-v2-permissions-select--danger { - border-color: var(--v2-state-fg-danger); - background: color-mix(in srgb, var(--v2-state-fg-danger) 8%, var(--v2-background-bg-base)); +.settings-v2-permissions-matrix-cell[data-danger] .settings-v2-permissions-matrix-cell-label::after { + content: " · risky"; + font-size: var(--font-size-xs); + font-weight: 400; color: var(--v2-state-fg-danger); } .settings-v2-permissions-matrix-help { - font-size: 11px; + font-size: var(--font-size-xs); color: var(--v2-text-text-faint); margin: 0; } + +/* table mode: five columns once the card can actually hold them */ +@container (min-width: 540px) { + .settings-v2-permissions-matrix { + gap: var(--space-2); + } + .settings-v2-permissions-matrix-header, + .settings-v2-permissions-rule { + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(4, minmax(72px, 96px)) 28px; + grid-template-areas: none; + gap: var(--space-2); + align-items: center; + } + .settings-v2-permissions-rule { + border: 0; + border-radius: 0; + padding: 0; + } + .settings-v2-permissions-rule + .settings-v2-permissions-rule { + border-top: var(--border-width) solid var(--v2-border-border-muted); + padding-top: var(--space-2); + } + .settings-v2-permissions-rule[data-default] .settings-v2-permissions-matrix-remove { + display: block; + visibility: hidden; + } + .settings-v2-permissions-group-label { + margin-top: var(--space-1); + } + .settings-v2-permissions-pattern-input { + grid-area: auto; + } + .settings-v2-permissions-matrix-pattern, + .settings-v2-permissions-matrix-remove, + .settings-v2-permissions-matrix-cell[data-group] { + grid-area: auto; + } + .settings-v2-permissions-matrix-pattern { + padding-block: 0; + } + .settings-v2-permissions-matrix-cell { + justify-content: flex-end; + border-top: 0; + padding-block: 0; + } + /* the header row carries the column names; keep the per-cell label for + assistive tech only */ + .settings-v2-permissions-matrix-cell-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +} + +/* ── model assignment ── */ .settings-v2-permissions-models { display: flex; flex-direction: column; - gap: 8px; - padding-top: 8px; - border-top: 0.5px solid var(--v2-border-border-base); + gap: var(--space-2); + padding-top: var(--space-2); + border-top: var(--border-width) solid var(--v2-border-border-base); + min-width: 0; } .settings-v2-permissions-models-title { - font-size: 12px; - font-weight: 600; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-emphasis); color: var(--v2-text-text-base); margin: 0; } .settings-v2-permissions-models-empty { - font-size: 12px; + font-size: var(--font-size-sm); color: var(--v2-text-text-muted); margin: 0; } .settings-v2-permissions-models-list { display: flex; flex-wrap: wrap; - gap: 6px; + gap: var(--space-2); } .settings-v2-permissions-model-chip { display: inline-flex; align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: 999px; + gap: var(--space-2); + max-width: 100%; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-full); background: var(--v2-background-bg-layer-02); - font-size: 12px; + font-size: var(--font-size-sm); color: var(--v2-text-text-base); - border: 0.5px solid var(--v2-border-border-muted); + border: var(--border-width) solid var(--v2-border-border-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .settings-v2-permissions-model-remove { border: 0; background: transparent; color: var(--v2-text-text-muted); cursor: pointer; - padding: 0 2px; + padding: 0 var(--space-1); + border-radius: var(--radius-sm); } .settings-v2-permissions-model-remove:hover { color: var(--v2-state-fg-danger); } -.settings-v2-permissions-model-select { - max-width: 320px; - width: 100%; - padding: 6px 8px; - border-radius: 6px; - border: 0.5px solid var(--v2-border-border-base); - background: var(--v2-background-bg-base); - font-size: 12px; +.settings-v2-permissions-model-remove:focus-visible { + outline: 2px solid var(--v2-border-border-focus); } .settings-v2-permissions-models-hint { - font-size: 11px; + font-size: var(--font-size-xs); color: var(--v2-text-text-faint); } -[data-component="permissions-tab"] .settings-v2-permissions-select--danger { - box-shadow: 0 0 0 1px var(--v2-state-fg-danger); -} .settings-v2-permissions-model-picker-help { - font-size: 11px; + font-size: var(--font-size-xs); color: var(--v2-text-text-faint); - margin: 0 0 6px; + margin: 0 0 var(--space-2); } .settings-v2-permissions-model-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 6px; + gap: var(--space-1); max-height: 180px; overflow-y: auto; - padding: 8px; - border: 0.5px solid var(--v2-border-border-base); - border-radius: 6px; + padding: var(--space-2); + border: var(--border-width) solid var(--v2-border-border-base); + border-radius: var(--radius-md); background: var(--v2-background-bg-base); + min-width: 0; } .settings-v2-permissions-model-option { display: flex; align-items: center; - gap: 8px; - font-size: 12px; + gap: var(--space-2); + font-size: var(--font-size-sm); color: var(--v2-text-text-base); cursor: pointer; - padding: 4px 6px; - border-radius: 4px; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + min-width: 0; } .settings-v2-permissions-model-option:hover { background: var(--v2-background-bg-layer-02); } +.settings-v2-permissions-model-option:focus-within { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: -2px; +} .settings-v2-permissions-model-option input[type="checkbox"] { flex-shrink: 0; } diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 37a8b3237..a87626a55 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -129,6 +129,20 @@ button, [role="button"], a, input, textarea, [data-slot="card"] { [data-action="prompt-submit"] svg { color: var(--accent-ink) !important; } [data-action="prompt-submit"]:hover { filter: brightness(1.06); transform: translateY(-1px); } +/* accent tag (badge-v2 `variant="accent"`): the shared primitive paints + --v2-text-text-contrast over bg-accent, which on dark is LIGHT text on + yellow — the one pairing the accent law forbids. Ink on a yellow fill is + always near-black, both schemes. Chips never wrap: the label is a single + line or it isn't a chip. The element selector is deliberate: badge-v2.css + is injected when the component lazy-loads (AFTER this file), so an + equal-specificity rule here would lose the cascade. */ +span[data-component="tag"][data-variant="accent"] { + background: var(--accent); + color: var(--accent-ink); + white-space: nowrap; + flex-shrink: 0; +} + /* the rail: controls use the control corner */ [data-component="sidebar-rail"] button, [data-component="chat-first-rail"] button { border-radius: var(--radius-md); }