Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/SMOKE_TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ reviewer.
- [ ] **Last-column guard.** In the Settings dialog, uncheck scopes one by
one. The final remaining checkbox refuses to uncheck (the grid would
otherwise render empty with no recovery from inside the dialog).
- [ ] **Path-collision banner (#153).** Open ClaudeScope against your real
`$HOME` (e.g. `Open project…` → pick your home directory). A yellow
banner appears directly under the toolbar: **Scopes share files**,
listing "Project and User both resolve to `~/.claude/settings.json`"
and the same for Local + User-Local. Try clicking a `→` move target
between two scopes that share a file: the apply should fail with a
typed error naming both scopes and the shared path. Close and
re-open against a normal project root; the banner disappears.
- [ ] **Kind-disagreement badge (#156).** Hand-edit a rule into two
scopes under different kinds — e.g. `Bash(git push)` as `allow` in
User and `deny` in Project. Reload. A red ⚠ badge appears next to
the rule row in **both** affected scope columns AND on the matching
chips in the Effective settings panel. Hover (or focus + Enter) the
badge: a popover lists every (scope, kind) pairing of the rule with
the highest-precedence one marked **wins by precedence**. Click the
badge to pin the popover; Esc or outside-click dismisses it.

## Audit log: History, undo, redo (non-sandbox runs only)

Expand Down
351 changes: 349 additions & 2 deletions src-tauri/src/commands.rs

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,44 @@ body {
color: var(--sandbox-text-strong);
}

/* Path-collision banner (#153). Reuses the sandbox color palette — both
are persistent warnings that live directly under the toolbar. The
list+explain stack distinguishes it from the single-line sandbox
banner so the user can tell them apart at a glance when both happen
to be up. */
.collision-banner {
background: var(--sandbox-bg);
color: var(--sandbox-text);
border-bottom: 1px solid var(--sandbox-border);
padding: 10px 16px;
font-size: 13px;
overflow-wrap: anywhere;
}

.collision-banner strong {
color: var(--sandbox-text-strong);
}

.collision-list {
margin: 4px 0;
padding-left: 20px;
}

.collision-list li {
margin: 2px 0;
}

.collision-list code {
background: var(--panel-2);
padding: 1px 4px;
border-radius: 3px;
}

.collision-explain {
margin: 4px 0 0;
opacity: 0.85;
}

.search {
position: relative;
flex: 0 1 320px;
Expand Down Expand Up @@ -1023,6 +1061,36 @@ button:disabled {
visibility 120ms ease;
}

/* Kind-disagreement badge (#156). Reuses the lint-warn shape but
recolors to --deny so a user can tell at a glance which of the two
warnings is on a row when both happen to fire. --deny is the
"structural problem" color in the rest of the UI; conflict between
allow/deny/ask is exactly that, vs. lint's heuristic shape check. */
.kind-conflict-warn {
color: var(--deny);
}
.kind-conflict-warn:hover,
.kind-conflict-wrap.is-open > .kind-conflict-warn {
color: var(--deny);
border-color: var(--deny);
}
.kind-conflict-warn:focus-visible {
color: var(--deny);
border-color: var(--deny);
}

.kind-conflict-list {
margin: 6px 0 0;
padding-left: 18px;
list-style: disc;
}
.kind-conflict-list li {
margin: 2px 0;
}
.kind-conflict-winner {
font-weight: 600;
}

/* Reason on top, then a muted heuristic disclaimer separated by a hairline.
The yellow ⚠ reads as authoritative on its own, so the popover spells out
that it's a best-effort shape check rather than a verdict from Claude
Expand Down
37 changes: 37 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,43 @@ export interface LoadedScopes {
scopes: ScopeView[];
combined_permissions: PermissionRules;
combined_origins: PermissionRuleOrigins;
/**
* Pairs (or larger groups) of scopes whose resolved file paths point at
* the same file on disk — the common case is launching ClaudeScope from
* `$HOME` itself, which collapses Project onto User (and Local onto
* UserLocal). Rendered as a top-of-app warning banner so the user can't
* miss it before treating the columns as if they were independent
* (#153).
*/
path_collisions: PathCollision[];
/**
* Rule strings that appear in multiple scopes under disagreeing kinds —
* e.g. `Bash(git *)` is `allow` in User but `deny` in Project. Rendered
* as a warning chip next to the affected rule rows; the chip's tooltip
* explains which kind wins by precedence (#156).
*/
kind_conflicts: KindConflict[];
}

/** One group of scopes whose resolved file paths point at the same on-disk
* file (#153). Mirrors Rust's `PathCollision`. Scopes are in `SCOPES`
* display order so the banner reads broad→narrow, same as the columns. */
export interface PathCollision {
scopes: Scope[];
path: string;
}

/** One rule with disagreeing kinds across scopes (#156). Mirrors Rust's
* `KindConflict`. Occurrences are in precedence order (highest first); the
* frontend treats `occurrences[0]` as the winner. */
export interface KindConflict {
rule: string;
occurrences: KindOccurrence[];
}

export interface KindOccurrence {
scope: Scope;
kind: PermissionKind;
}

/**
Expand Down
165 changes: 165 additions & 0 deletions src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
DeleteLeafPreview,
DeleteLeafRequest,
JsonValue,
KindConflict,
KnownProject,
LoadedScopes,
MoveLeafKind,
Expand Down Expand Up @@ -587,6 +588,12 @@ export function renderApp(root: HTMLElement, props: AppProps): void {
root.appendChild(header(props));
const banner = sandboxBanner(props.runtime);
if (banner) root.appendChild(banner);
// Path-collision banner (#153) — only renders when the resolved scope
// paths overlap (the common case: launching from $HOME itself). Goes
// under the sandbox banner so the most contextual / surprising
// warnings stack near the topbar.
const collisionBanner = pathCollisionsBanner(props.scopes);
if (collisionBanner) root.appendChild(collisionBanner);

if (!props.scopes) {
const empty = document.createElement("div");
Expand Down Expand Up @@ -654,6 +661,51 @@ function restoreSearchFocus(
* is "the user can't forget they're in scratch mode," so we deliberately
* don't make this dismissible.
*/
/**
* Warning banner shown when two or more scopes resolved to the same file
* on disk (#153). The common case: launching ClaudeScope with the
* project root equal to `$HOME` — Project and User then collapse onto the
* same `~/.claude/settings.json` and Local/UserLocal onto the same
* `settings.local.json`. The columns still render, but every cross-scope
* move between a colliding pair is rejected by the backend; this banner
* names the affected scopes + shared path so the user notices before
* trying.
*/
function pathCollisionsBanner(scopes: LoadedScopes | null): HTMLElement | null {
if (!scopes || scopes.path_collisions.length === 0) return null;
const banner = document.createElement("div");
banner.className = "collision-banner";
banner.setAttribute("role", "note");

const label = document.createElement("strong");
label.textContent = "Scopes share files";
banner.appendChild(label);

const list = document.createElement("ul");
list.className = "collision-list";
for (const c of scopes.path_collisions) {
const item = document.createElement("li");
const labels = c.scopes.map((s) => SCOPE_LABELS[s]).join(" and ");
const intro = document.createElement("span");
intro.textContent = `${labels} both resolve to `;
const path = document.createElement("code");
path.textContent = c.path;
item.appendChild(intro);
item.appendChild(path);
list.appendChild(item);
}
banner.appendChild(list);

const explain = document.createElement("p");
explain.className = "collision-explain";
explain.textContent =
"Moves between these scopes are disabled — they'd be no-ops or overwrite the file with itself. " +
"Open a different project root to give the scopes distinct files.";
banner.appendChild(explain);

return banner;
}

function sandboxBanner(runtime: RuntimeInfo): HTMLElement | null {
if (!runtime.home_override && !runtime.project_override) return null;
const banner = document.createElement("div");
Expand Down Expand Up @@ -1058,6 +1110,18 @@ function combinedPanel(loaded: LoadedScopes, props: AppProps, lowerQuery: string
chipWrap.appendChild(originWrap);
const badge = lintBadge(rule);
if (badge) chipWrap.appendChild(badge);
// Kind-disagreement badge (#156). The combined panel deduplicates
// each kind separately, so the *same* rule string can appear in
// both `combined.allow` and `combined.deny`. The badge anchors on
// the highest-precedence (scope, kind) for this row — the first
// entry of `combined_origins[kind][i]` — so it sits exactly where
// the rule "lives" in this kind's column.
const conflictAnchorScope = (allOrigins[i] ?? [])[0];
if (conflictAnchorScope) {
const conflict = findKindConflict(rule, conflictAnchorScope, kind, loaded.kind_conflicts);
const cBadge = kindConflictBadge(conflict);
if (cBadge) chipWrap.appendChild(cBadge);
}
attachContextMenu(chipWrap, () =>
combinedChipContextMenuItems(rule, allOrigins[i] ?? [], props),
);
Expand All @@ -1069,6 +1133,98 @@ function combinedPanel(loaded: LoadedScopes, props: AppProps, lowerQuery: string
return panel;
}

/**
* Look up a rule's cross-scope kind disagreement (#156). Returns the
* matching `KindConflict` only when the *current* row's (scope, kind) is
* one of the occurrences — a rule that disagrees across two *other*
* scopes shouldn't render a badge on a row that's not part of the
* conflict. For the combined panel, callers pass the panel's "winning"
* scope (first entry of the rule's `combined_origins`) so the badge
* only sits on rule rows that are themselves part of the disagreement.
*/
function findKindConflict(
rule: string,
scope: Scope,
kind: PermissionKind,
conflicts: KindConflict[],
): KindConflict | null {
for (const c of conflicts) {
if (c.rule !== rule) continue;
if (c.occurrences.some((o) => o.scope === scope && o.kind === kind)) return c;
}
return null;
}

/**
* Warning badge for a permission rule whose kind disagrees across scopes
* (#156). Shape mirrors `lintBadge`: a focusable ⚠ button paired with a
* popover so the explanation surfaces consistently on hover, focus, and
* click. The popover names every (scope, kind) the rule appears under,
* with the highest-precedence entry labeled as the winner.
*
* Returns null when `conflict` is null so callers can write
* `chip.append(kindConflictBadge(...) ?? document.createTextNode(""))`-
* style code without branching.
*/
function kindConflictBadge(conflict: KindConflict | null): HTMLElement | null {
if (conflict === null) return null;

const wrap = document.createElement("span");
wrap.className = "lint-warn-wrap kind-conflict-wrap";
const popoverId = `kind-conflict-popover-${++lintPopoverSeq}`;

const btn = document.createElement("button");
btn.type = "button";
btn.className = "lint-warn kind-conflict-warn";
btn.textContent = "⚠";
btn.setAttribute("aria-label", "Rule kind disagrees across scopes");
btn.setAttribute("aria-describedby", popoverId);

const pop = document.createElement("span");
pop.id = popoverId;
pop.className = "lint-warn-popover kind-conflict-popover";
pop.setAttribute("role", "tooltip");

const intro = document.createElement("span");
intro.className = "lint-warn-popover-reason";
intro.textContent = `Rule appears in multiple scopes with different kinds:`;
pop.appendChild(intro);

const list = document.createElement("ul");
list.className = "kind-conflict-list";
for (let i = 0; i < conflict.occurrences.length; i++) {
const o = conflict.occurrences[i];
const item = document.createElement("li");
const isWinner = i === 0;
item.textContent = `${SCOPE_LABELS[o.scope]}: ${KIND_LABELS[o.kind]}${
isWinner ? " (wins by precedence)" : ""
}`;
if (isWinner) item.className = "kind-conflict-winner";
list.appendChild(item);
}
pop.appendChild(list);

const note = document.createElement("span");
note.className = "lint-warn-popover-note";
note.textContent =
"Highest-precedence scope wins for the effective union, but Claude Code's runtime " +
"resolution may apply additional rules.";
pop.appendChild(note);

btn.addEventListener("click", (e) => {
e.stopPropagation();
if (openPinnedPopover === wrap) {
closePinnedPopover();
} else {
pinPopover(wrap);
}
});

wrap.appendChild(btn);
wrap.appendChild(pop);
return wrap;
}

/**
* Returns a small warning element when the rule fails shape lint, or null
* when the rule looks well-formed. The lint is intentionally lenient — we
Expand Down Expand Up @@ -2146,6 +2302,15 @@ function treeLeaf(
row.appendChild(wrapWithOriginTooltip(code, [scope]));
const badge = lintBadge(rule);
if (badge) row.appendChild(badge);
// Kind-disagreement badge (#156). Surfaces when the same rule string
// exists in another scope under a different kind (allow vs deny vs
// ask). The popover lists every (scope, kind) so the user can spot
// a likely typo or stale rule without scanning every column.
if (props?.scopes) {
const conflict = findKindConflict(rule, scope, permKind, props.scopes.kind_conflicts);
const cBadge = kindConflictBadge(conflict);
if (cBadge) row.appendChild(cBadge);
}
if (props) {
// Inline arrow buttons dropped in #152 — right-click context
// menu (with the full Move-to submenu, including cross-project
Expand Down
6 changes: 6 additions & 0 deletions test/fixtures/loadedScopes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {
JsonValue,
KindConflict,
LoadedScopes,
PathCollision,
PermissionKind,
PermissionRuleOrigins,
PermissionRules,
Expand Down Expand Up @@ -96,6 +98,8 @@ interface LoadedScopesOverrides {
scopes?: ScopeViewOverrides[];
combined_permissions?: Partial<PermissionRules>;
combined_origins?: Partial<PermissionRuleOrigins>;
path_collisions?: PathCollision[];
kind_conflicts?: KindConflict[];
}

/**
Expand Down Expand Up @@ -147,6 +151,8 @@ export function buildLoadedScopes(overrides: LoadedScopesOverrides = {}): Loaded
scopes,
combined_permissions: { ...combined, ...(overrides.combined_permissions ?? {}) },
combined_origins: { ...origins, ...(overrides.combined_origins ?? {}) },
path_collisions: overrides.path_collisions ?? [],
kind_conflicts: overrides.kind_conflicts ?? [],
};
}

Expand Down
Loading
Loading