Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3d887fd
feat(right-panel): openInCodeTab front door + right-click "Open at li…
srid May 14, 2026
ce1546a
fix(police): prefer-ts-pattern — dispatch CodeContextMenu items via m…
srid May 14, 2026
fc2d1c9
refactor(police): elegance — snapshot path() once in buildItems
srid May 14, 2026
e267f90
refactor(police): elegance — skip openCodeAt patch when already at ta…
srid May 14, 2026
ee14727
refactor(police): elegance — trim narrating comment on diff-view onOpen
srid May 14, 2026
e50f4ac
refactor(police): elegance — drop future-tense narration from targetM…
srid May 14, 2026
e268a5b
feat(ui): icons on context-menu items so Copy vs Open is glyphic, not…
srid May 14, 2026
c1bb9b7
fix(solid-pierre): expand ancestors of selectedPath so external navig…
srid May 14, 2026
623437d
feat(right-panel): right-click on a line is the single context-menu e…
srid May 14, 2026
325e240
fix(solid-pierre): include selectedPath ancestors in initialExpandedP…
srid May 14, 2026
3271b6f
fix(right-panel): batch openInCodeTab's two writes so CodeTab's reset…
srid May 14, 2026
46d7a57
docs(solid-pierre): note Pierre's missing scroll-to-path API and link…
srid May 14, 2026
59b4711
refactor(hickey): elegance — merge clickLineGutter / rightClickLineIn…
srid May 14, 2026
1f04171
refactor(hickey): elegance — name the resetKey guard predicate to sur…
srid May 14, 2026
7e89d78
docs(right-panel): annotate pendingOpen signal's single-owner assumption
srid May 14, 2026
c00cb08
docs(solid-pierre): explain snapshot vs reactive selectedPath reads a…
srid May 14, 2026
aaf4e56
fix(police): no-dead-code — remove unused right-click view-root step …
srid May 14, 2026
fd1b01a
Merge branch 'master' into open-in-code-tab
srid May 14, 2026
d23d247
refactor(police): reuse — promote ancestorDirectoryPaths to @kolu/sol…
srid May 14, 2026
c0d08b7
refactor(police): elegance — trim Terminal-archaeology from openInCod…
srid May 14, 2026
26ecf67
refactor(police): elegance — trim caller speculation from FileTree on…
srid May 14, 2026
25382e0
refactor(police): elegance — trim narration from interactWithGutterLi…
srid May 14, 2026
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
41 changes: 40 additions & 1 deletion packages/client/src/right-panel/CodeMenuFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CodeContextMenu,
type CodeContextMenuController,
} from "../ui/CodeContextMenu";
import type { LineRef } from "../ui/lineRef";
import { type LineSelection, useLineSelection } from "../ui/useLineSelection";

export type CodeMenuFrameProps = {
Expand All @@ -25,19 +26,57 @@ export type CodeMenuFrameProps = {
* controller so a terminal `path:line` click drives both the
* Pierre highlight AND the right-click menu's "Copy path:N" item. */
initialSelectedLines?: SelectedLineRange | null;
/** When provided, adds an "Open <path>:<line>" entry to the context
* menu that dispatches the selected ref to the host (typically a
* call to `openInCodeTab`). Omit for viewers where "open" is a
* no-op (the file is already on screen at line precision). */
onOpen?: (ref: LineRef) => void;
};

/** Walk the contextmenu event's composed path (which pierces Pierre's
* open shadow DOM, where `event.target` would otherwise be retargeted
* to the shadow host) and return the line number from the first
* element carrying `data-column-number`. Returns null when the
* right-click landed outside any gutter line — empty area, scrollbar,
* decoration row — so the host can skip opening a menu entirely. */
function lineFromContextMenu(event: MouseEvent): number | null {
for (const node of event.composedPath()) {
if (!(node instanceof Element)) continue;
const raw = node.getAttribute("data-column-number");
if (raw === null) continue;
const n = Number(raw);
if (Number.isFinite(n) && n >= 1) return n;
}
return null;
}

export const CodeMenuFrame: Component<CodeMenuFrameProps> = (props) => {
let menuCtrl: CodeContextMenuController | undefined;
const selection = useLineSelection(() => props.path, {
initialRange: () => props.initialSelectedLines,
onOpen: () => props.onOpen,
});
return (
<div
// Attach contextmenu via addEventListener so the host div doesn't
// carry interactive JSX props — the inner Pierre canvas is the
// actual interactive surface; the host is layout only.
ref={(el) => el.addEventListener("contextmenu", (e) => menuCtrl?.open(e))}
ref={(el) =>
el.addEventListener("contextmenu", (e) => {
// Right-click on a gutter line is the single entry point for
// the context menu: it both selects the line and opens the
// menu in one gesture. Right-clicks elsewhere (whitespace,
// scrollbar, decoration row) clear the range and produce no
// menu — `buildItems` returns empty when no range is set,
// so `menuCtrl.open` short-circuits without preventing the
// browser default.
const line = lineFromContextMenu(e);
selection.handleSelect(
line === null ? null : { start: line, end: line },
);
menuCtrl?.open(e);
})
}
class="h-full w-full"
>
{props.children(selection)}
Expand Down
85 changes: 55 additions & 30 deletions packages/client/src/right-panel/CodeTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,12 @@ import {
} from "../ui/pierreTheme";
import { resolveLineRefPath } from "../ui/lineRef";
import BrowseFileView from "./BrowseFileView";
import { type CodeOpenRequest, pendingCodeOpen } from "./codeNavigation";
import CodeMenuFrame from "./CodeMenuFrame";
import {
openInCodeTab,
type OpenInCodeTabRequest,
pendingOpen,
} from "./openInCodeTab";
import { projectFileTreeSearch } from "./fileSearch";
import FileSearchInput from "./FileSearchInput";
import ModeChipPicker, { type ModeOption } from "./ModeChipPicker";
Expand Down Expand Up @@ -176,57 +180,66 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => {
// absolute path or `null`, never the empty string that would alias
// null.
const resetKey = createMemo(() => `${repoPath() ?? ""}::${view()}`);

/** The resetKey effect runs BEFORE the pendingOpen effect by
* registration order. When a navigation request is about to land in
* the new (repo, mode) — same repoRoot, target mode equals the
* freshly-ticked `view()`, and the request hasn't already been
* consumed by `handled()` — the resetKey effect must skip its clear,
* or the pendingOpen effect would null what we're about to set.
* This predicate names the cross-effect temporal coupling so the
* guard isn't a wall of inline conjunctions a future editor has to
* re-derive. The `batch()` in `openInCodeTab` ensures both writes
* (`view` and `pendingOpen`) commit before either effect fires; the
* registration-order discipline survives ABOVE that. */
const isPendingOpenAboutToLand = (): boolean => {
const req = pendingOpen();
return (
req !== null &&
req.repoRoot === repoPath() &&
req.targetMode === view() &&
handled()?.request !== req
);
};

createEffect(
on(
resetKey,
() => {
setSearchQuery("");
// Skip the selectedPath clear when an incoming request is
// about to land in the new mode — the resetKey effect runs
// before the pendingCodeOpen effect (registration order), and
// an unconditional clear would null what we're about to set.
// Reading `req.targetMode` (not `view()`) makes the guard
// robust to user-driven mode flips that race the click.
const req = pendingCodeOpen();
if (
req &&
req.repoRoot === repoPath() &&
req.targetMode === view() &&
handled()?.request !== req
) {
return;
}
if (isPendingOpenAboutToLand()) return;
setSelectedPath(null);
},
{ defer: true },
),
);

// Consume-once record for the latest pendingCodeOpen tick. Holds
// the full request object (reference identity discriminates two
// structurally-identical clicks — `requestCodeOpen` mints a fresh
// Consume-once record for the latest pendingOpen tick. Holds the
// full request object (reference identity discriminates two
// structurally-identical clicks — `openInCodeTab` mints a fresh
// object per call) alongside the resolved path. Storing the
// request here lets `selectedRange` derive its value without
// re-running `resolveLineRefPath` (single resolution site per
// request) and lets `resetKey` know whether a pending request
// has already been applied.
const [handled, setHandled] = createSignal<{
request: CodeOpenRequest;
request: OpenInCodeTabRequest;
resolvedPath: string | null;
} | null>(null);

// Honor terminal file-ref clicks. The effect waits for the live
// `fsListAll` stream to settle so resolution can validate against
// a complete file list — otherwise a request fired during boot
// would toast "not found" on a path that just hasn't been
// enumerated yet. The terminal click handler is the sole site that
// flips the panel to browse mode; this effect only sets
// Honor every `openInCodeTab` request — terminal file-ref clicks,
// right-click "Open path:N" entries, and any future producer. The
// effect waits for the live `fsListAll` stream to settle so
// resolution can validate against a complete file list — otherwise
// a request fired during boot would toast "not found" on a path
// that just hasn't been enumerated yet. `openInCodeTab` flips the
// panel to browse mode itself; this effect only sets
// `selectedPath`. The `resetKey` effect above guards against
// clearing selectedPath when this effect is about to set it.
createEffect(
on(
() => {
const req = pendingCodeOpen();
const req = pendingOpen();
const paths = treePaths();
const isPending = allPaths.pending();
return { req, repo: repoPath(), paths, isPending };
Expand Down Expand Up @@ -263,7 +276,7 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => {
//
// No `equals` override: two clicks on the same `path:line` produce
// structurally identical `{start, end}` but distinct request
// objects (`requestCodeOpen` mints a fresh one per call), so the
// objects (`openInCodeTab` mints a fresh one per call), so the
// memo emits a fresh value on every click. Pierre's
// `InteractionManager.setSelection` re-renders when the selection
// is "dirty" — and tearing down the gutter (panel collapse,
Expand All @@ -275,7 +288,7 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => {
start: number;
end: number;
} | null>(() => {
const req = pendingCodeOpen();
const req = pendingOpen();
if (!req) return null;
const h = handled();
if (!h || h.request !== req || h.resolvedPath === null) return null;
Expand Down Expand Up @@ -538,7 +551,19 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => {
</Match>
<Match when={diff()}>
{(d) => (
<CodeMenuFrame path={path}>
<CodeMenuFrame
path={path}
onOpen={(ref) => {
// Diff paths are repo-relative; cwd is irrelevant.
const repo = repoPath();
if (repo === null) return;
openInCodeTab({
ref,
repoRoot: repo,
targetMode: "browse",
});
}}
>
{(selection) => (
// `<Virtualizer>` is the scroll container —
// `<FileDiff>` consumes its context and
Expand Down
39 changes: 0 additions & 39 deletions packages/client/src/right-panel/codeNavigation.ts

This file was deleted.

17 changes: 2 additions & 15 deletions packages/client/src/right-panel/fileSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
* the directories the wrapper should ensure are open so matches don't
* hide behind a collapsed parent on first paint. */

import { ancestorDirectoryPaths } from "@kolu/solid-pierre";

type FileTreeSearchProjection = {
projectedPaths: string[];
expandedAncestors: string[];
Expand All @@ -39,21 +41,6 @@ function pathContainsTokensInOrder(
return true;
}

/** Pierre uses `getAncestorDirectoryPaths` internally to drive
* expansion in `hide-non-matches` mode. Mirror that exact shape so
* the wrapper's expansion request reaches every dir Pierre infers
* from the projected paths. */
function ancestorDirectoryPaths(path: string): string[] {
const normalized = path.endsWith("/") ? path.slice(0, -1) : path;
if (normalized.length === 0) return [];
const segments = normalized.split("/");
const out: string[] = [];
for (let i = 1; i < segments.length; i += 1) {
out.push(`${segments.slice(0, i).join("/")}/`);
}
return out;
}

export function projectFileTreeSearch(
paths: string[],
query: string,
Expand Down
59 changes: 59 additions & 0 deletions packages/client/src/right-panel/openInCodeTab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/** Front door for "open this file:line in the Code tab". Every producer
* — terminal-link click, right-click "Open path:N" context-menu entry,
* future surfaces — calls `openInCodeTab(...)` instead of writing the
* preferences patch and pending-request signal separately. The function
* encapsulates the paired writes (panel-uncollapse + tab + browse-mode +
* pending request) so the SolidJS effect-ordering invariant lives here,
* not at every call site.
*
* Latest request wins; callers don't clear it. Each call mints a fresh
* request object — two clicks on the same `path:line` are distinct by
* reference, which is what lets `CodeTab` tell them apart even when
* their `ref` content matches and re-paint the highlight. */

import type { CodeTabView } from "kolu-common/surface";
import { batch, createSignal } from "solid-js";
import type { LineRef } from "../ui/lineRef";
import { useRightPanel } from "./useRightPanel";

export interface OpenInCodeTabRequest {
/** Parsed `path:line[-end]` to navigate to. The path is interpreted
* relative to `repoRoot` (or, when present, cwd-relative under
* `repoRoot`) by `CodeTab` via `resolveLineRefPath`. */
ref: LineRef;
/** Per-terminal git repo root that `ref.path` is relative to (when
* relative). Absolute paths beneath this root are also accepted —
* the resolver normalizes both shapes. */
repoRoot: string;
/** Terminal cwd at the time of the request. Drives the "user typed
* `bar.ts:42` while standing in a subdirectory of the repo" case;
* undefined falls back to repo-relative interpretation only. */
cwd?: string;
/** Which Code-tab sub-mode the request expects to land in.
* Producers that don't track an authoring mode pass `"browse"`. */
targetMode: CodeTabView;
}

// Module-level singleton. Right-panel state is a singleton in Kolu —
// one panel, one CodeTab — and the navigation request is meant for
// the unique consumer. If kolu ever mounts multiple CodeTab instances
// (split panels, multi-window), this signal must move into a
// SolidJS context or scope to a per-panel store, otherwise concurrent
// consumers will race on each other's pending requests.
const [pending, setPending] = createSignal<OpenInCodeTabRequest | null>(null);

export const pendingOpen = pending;

/** Open the right panel's Code tab at `req.targetMode` showing `req.ref`.
* The two reactive writes (preferences patch + pending-request signal)
* are wrapped in `batch()` so SolidJS defers dependent effects until
* both have committed. Without the batch, the preferences optimistic
* update ticks `view()` first, fires `CodeTab`'s `resetKey` effect
* when `pendingOpen()` is still null, the guard fails, and the
* selectedPath the user navigated to gets cleared. */
export function openInCodeTab(req: OpenInCodeTabRequest): void {
batch(() => {
useRightPanel().openCodeAt(req.targetMode);
setPending(req);
});
}
22 changes: 15 additions & 7 deletions packages/client/src/right-panel/useRightPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,26 @@ export function useRightPanel() {
...(mode !== undefined && { codeMode: mode }),
},
}),
/** Atomic "open the code browser at this file" — uncollapse the
* panel, switch to Code, force browse mode. Single patch so the
* UI ticks once instead of three times when callers need all
* three transitions together. */
openCodeBrowser: () =>
/** Atomic "open the Code tab at `mode`" — uncollapse the panel,
* switch to Code, set the requested sub-mode. Single preferences
* patch so the UI ticks once instead of three times when callers
* need all three transitions together. Skips the patch when the
* panel is already in the target state (every diff→browse and
* browse→browse `openInCodeTab` would otherwise round-trip a
* three-field preferences write to the server). */
openCodeAt: (mode: CodeTabView) => {
const cur = rp();
if (!cur.collapsed && cur.activeTab === "code" && cur.codeMode === mode) {
return;
}
updatePreferences({
rightPanel: {
collapsed: false,
activeTab: "code",
codeMode: "browse",
codeMode: mode,
},
}),
});
},
/** Change the sub-mode within the Code tab. */
setCodeMode: (mode: CodeTabView) =>
updatePreferences({ rightPanel: { codeMode: mode } }),
Expand Down
Loading