diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38ea6b04f..9ed9836c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,12 @@ jobs: pnpm install --frozen-lockfile pnpm build + # biome is the frontend's gofmt + vet: formatting and lint in one pass. + # Warnings (the a11y backlog) are reported without failing; errors fail. + - name: Frontend format + lint + working-directory: frontend + run: pnpm check + - name: gofmt run: | unformatted="$(gofmt -l .)" diff --git a/CLAUDE.md b/CLAUDE.md index dd92d6780..babb5f2f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ Frontend in isolation: `cd frontend && pnpm run build` (runs `tsc` + `vite build ## Local Gate (before every commit / PR) - `gofmt -l .` clean (fix with `gofmt -w .`) and `go vet ./...` clean. +- `cd frontend && pnpm check` clean — biome is the frontend's gofmt + vet (fix with `pnpm format`). - `go test ./...` (backend) and `cd frontend && pnpm test` (frontend) green — or `task test` for both. - `cd frontend && pnpm build` succeeds (tsc typecheck + vite). - Touched an OS seam or a `_test.go` build tag? Run the same cross-compile loop CI runs: @@ -94,7 +95,7 @@ Deliberate limits and shortcuts — one line of *what and where*; the mechanism - **Session cwd is polled** from the PTY child (`internal/terminal/cwd.go`, per-OS readers behind build tags); a failed read degrades to the session's start directory. Tracks the direct child only, not nested shells. -- **git status is polled** — one shared poller per repository path (`frontend/src/lib/git-status-store.ts`); the +- **git status is polled** — one shared poller per repository path (`frontend/src/lib/git/git-status-store.ts`); the lich plugin's `session-touched` hook nudges an immediate refresh. An fs watcher is the upgrade path. - **Context-window usage is read off the transcript** (`internal/terminal/usage_claude.go`): on a turn-boundary status hook, the tail of `~/.claude/projects//.jsonl` is parsed for the last main-thread assistant @@ -111,7 +112,7 @@ Deliberate limits and shortcuts — one line of *what and where*; the mechanism pinned at 47821; `LICH_LISTEN_PORT` overrides it, `LICH_PORT` is the distinct per-session hook variable), the workspace in SQLite (`/lich/lich.db`, `internal/store`). Closing a session deletes its row; keeping a worktree parks its session for a later resume; a closed project is hidden, never deleted. -- **Hidden sessions are serialized and destroyed**: 2MB replay rings on both sides (`frontend/src/lib/replay-buffer.ts` +- **Hidden sessions are serialized and destroyed**: 2MB replay rings on both sides (`frontend/src/lib/terminal/replay-buffer.ts` page-side, `internal/terminal/replay.go` backend-side — the latter survives a full page reload). waveterm's disk filestore is the upgrade path if size ever matters. - **Terminal I/O rides the loopback WebSocket**; when the socket is down, output falls back to `/events` and input diff --git a/docs/chromium-shell.md b/docs/chromium-shell.md index aa0a35792..6c433abc0 100644 --- a/docs/chromium-shell.md +++ b/docs/chromium-shell.md @@ -45,7 +45,7 @@ its CDP surface): Why lich is ~80% there already: - Terminal I/O already rides a loopback WebSocket with token auth - (`internal/terminal/transport.go` ↔ `frontend/src/lib/term-transport.ts`) — + (`internal/terminal/transport.go` ↔ `frontend/src/lib/terminal/term-transport.ts`) — Chromium connects to it unchanged. - The frontend is already embedded and static; serving it over HTTP replaces handing it to the webview. diff --git a/docs/hooks/session-start.md b/docs/hooks/session-start.md index 2cd1bb1f1..c8cec3c02 100644 --- a/docs/hooks/session-start.md +++ b/docs/hooks/session-start.md @@ -53,13 +53,13 @@ holds the id of the Claude session currently in the card. `New`) emits the global app event `session-agent` (`{id, agent: "claude"}`): a report is proof Claude runs in this PTY, so a shell card wears Claude's icon while it does. The mark lives in - `frontend/src/lib/session-agent-store.ts`, never the store: it clears on the + `frontend/src/lib/session/session-agent-store.ts`, never the store: it clears on the session-state contract's `idle` (SessionEnd — Claude left) and on every PTY spawn (the backend emits an empty agent), so it dies with the process that earned it. The card's persisted kind — what a respawn runs, what the resume prompt keys on — never changes. - **Consumer** — the resume prompt. `LoadState` hydrates the id onto the - frontend session (`resumableSession` in `frontend/src/lib/sessions.ts`), and + frontend session (`resumableSession` in `frontend/src/lib/session/sessions.ts`), and the first time a restored card is opened `TerminalHost` asks before spawning: accepting passes the id to `terminal.Start`, which spawns `claude --resume `. @@ -80,7 +80,7 @@ holds the id of the Claude session currently in the card. - **Only Claude Code resumes.** The field and the column are provider-agnostic, but `--resume` is a Claude Code flag: `resumeArgs` in `internal/terminal/terminal.go` and `resumableSession` in - `frontend/src/lib/sessions.ts` both gate on the claude kind. Another provider + `frontend/src/lib/session/sessions.ts` both gate on the claude kind. Another provider reporting an id would have it stored and ignored until its own resume flag is wired. - **The deprecated `claude_session_id` alias stays until the install gate can diff --git a/docs/hooks/session-state.md b/docs/hooks/session-state.md index a83a5fb4f..4d4e6d4ff 100644 --- a/docs/hooks/session-state.md +++ b/docs/hooks/session-state.md @@ -54,7 +54,7 @@ what re-arms the spinner after them. Every tool re-reports `busy` (idempotent); - **UI push** — `internal/terminal/terminal.go`: emits the global app event `session-status` (`{id, state}`). Global rather than per-session because its consumers outlive any one card. -- **Store** — `frontend/src/lib/session-status-store.ts`: one subscription taken +- **Store** — `frontend/src/lib/session/session-status-store.ts`: one subscription taken at page load keeps the last state of every session, keyed by id. The card cannot hold it: the sidebar only renders cards for the active project, so switching projects unmounts them, and a status reported meanwhile would be lost. @@ -66,7 +66,7 @@ what re-arms the spinner after them. Every tool re-reports `busy` (idempotent); `busy` over `done`), shown only while the project is not the active one. A `done` stops badging once the project has been on screen; `busy` and `waiting` badge for as long as they hold, being live states rather than notifications. -- **Toast + route** — `frontend/src/lib/projects.tsx`: raises an actionable toast +- **Toast + route** — `frontend/src/providers/projects.tsx`: raises an actionable toast that navigates to the session's card when a report says `waiting`, skipped for the session already focused. It reads the raw event rather than the store: the store collapses a repeat state into no notification, which would swallow a diff --git a/docs/hooks/session-title.md b/docs/hooks/session-title.md index 012487c51..247580288 100644 --- a/docs/hooks/session-title.md +++ b/docs/hooks/session-title.md @@ -52,7 +52,7 @@ while the label is still automatic (see below), so a stable title is idempotent. Returns whether the label actually changed. - **Live update** — `internal/terminal/terminal.go`: when the label changed, emits the global app event `session-title` (`{id, label}`); - `frontend/src/lib/projects.tsx` mirrors it into session state so the card + `frontend/src/providers/projects.tsx` mirrors it into session state so the card updates without a reload. ## Known ceilings diff --git a/docs/hooks/session-touched.md b/docs/hooks/session-touched.md index 699dbb5e0..2c03050b1 100644 --- a/docs/hooks/session-touched.md +++ b/docs/hooks/session-touched.md @@ -39,9 +39,9 @@ the hook's stdin payload if a single script filters instead of per-tool matchers session id. - **UI push** — `internal/terminal/terminal.go`: emits the global app event `session-touched` (`{id}`). -- **Refresh** — `frontend/src/lib/projects.tsx`: resolves the session id to the +- **Refresh** — `frontend/src/providers/projects.tsx`: resolves the session id to the path its card watches (its worktree, else the project path) and calls - `refreshGitStatus(path)` (`frontend/src/lib/useGitStatus.ts`), which fetches + `refreshGitStatus(path)` (`frontend/src/lib/git/use-git-status.ts`), which fetches that path now, ahead of the poll tick. A no-op when no card watches the path (the session lives in a background project), so it costs no git call. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index af10ee604..4e0937999 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -13,6 +13,29 @@ React 18 + TypeScript + Vite, built to a static bundle that the Go binary embeds - **Terminal** is xterm.js 6 + WebGL. **Code / diff** is CodeMirror 6. **Icons** are lucide; file-type logos are `devicons-react`. **Toasts** are sonner. **DnD** is dnd-kit. +## Layout + +``` +src/ +├── providers/ context Providers — the app's state layer (projects, settings) +├── components/ one file per component, named after what it exports +│ ├── common/ shared across features (Notice, EmptyScreen, CloseButton, …) +│ ├── ui/ shadcn primitives — CLI output, see below +│ └── / diff · dock · pulls · settings · sidebar · tabs +└── lib/ no React elements; a `.tsx` in here is misfiled + ├── session/ sessions, their events, the four per-session stores + hooks + ├── terminal/ xterm/PTY plumbing: term-*, replay, paste and setup queues + ├── git/ status poller, diff parsing, file tree, branch names + ├── pulls/ pull-request lookup, detail, diff, viewed ticks + ├── update/ the app, plugin and patch-notes gates + └── *.ts cross-cutting: rpc, api-types, app-events, utils, keyed-store, + the card stores, hotkeys, paths, panel width, dnd +``` + +A store and the hook that reads it are a pair and live together (`git/git-status-store.ts` beside +`git/use-git-status.ts`) — the folders group by domain, never by kind. Files are kebab-case except +components, which are PascalCase after their export. Tests sit next to what they test. + ## Stack specifics - **Tailwind v4, CSS-first.** No `tailwind.config.*` — tokens and `@theme` live in `src/index.css`, wired via @@ -28,18 +51,39 @@ React 18 + TypeScript + Vite, built to a static bundle that the Go binary embeds ```bash pnpm build # tsc typecheck + vite build (the real gate) pnpm test # vitest run +pnpm check # biome format + lint check (CI runs this; errors fail) +pnpm format # biome, writing the fixes ``` Wart: `pnpm test`/`build` can trip an implicit install that dies on `ERR_PNPM_IGNORED_BUILDS`. When it does, run the binaries directly: `./node_modules/.bin/vitest run` or `pnpm exec vitest run`. +## Formatting and lint — biome + +**biome** (`biome.jsonc`) is the frontend's gofmt + vet, in one binary. There is no prettier and no eslint; +`eslint-disable` comments are dead here — the directive is `biome-ignore : `. + +- 2-space indent, double quotes, no semicolons, `lineWidth` 100 (measured as the least-churn width over the + source at adoption). +- A few recommended rules are off on purpose, each with its reason in `biome.jsonc` — read it before turning + one back on. Import order is deliberately not enforced. +- Warnings do not fail the gate; errors do. The standing warning backlog is the a11y one: `role="button"` + spans that take a click but no key, and the `role="separator"` resize handles. + ## State — no state library zustand and tanstack-query were both rejected (PR #13). The pattern is a **module-level store + -`useSyncExternalStore`**; see any `src/lib/*-store.ts`. Backend→frontend events are **global** payloads +`useSyncExternalStore`**; see any `*-store.ts`. Backend→frontend events are **global** payloads `{ id, ... }` on the events channel, never per-session sockets — subscribe once, filter by id. Do not add a state dep; extend the store pattern. +A per-session (or per-path) readout is `createKeyedStore` + `useKeyedStore`: one value per key, subscribers +scoped to that key, entries outliving their listeners because a card unmounts with its project while its +session keeps running. A new one is the event wiring and nothing else — cwd, agent and usage are three or +four lines each. Reach for it before hand-rolling a `Map` of listener sets. `git-status-store` and +`session-status-store` keep their own maps: one owns a poll cadence, the other a seen flag and a +cross-project queue, and neither fits without widening the primitive for a single caller. + ## Testing - vitest runs in the **node environment — there is no jsdom**. The gate covers pure logic (stores, parsers, diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 6400287cf..04146d87e 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -79,7 +79,7 @@ inactive project tab (`ProjectTab`). everywhere. Small controls (list rows, menu items, ghost buttons) sit around `rounded-md`. - Density is high but breathing: list rows `~9px` vertical padding, `gap-1`–`gap-1.5` between them. - Icons are **lucide** at `size-3.5`/`size-4`; `size-3` inside dense meta. Provider marks come from - `provider-icons.tsx`, file-type logos from `devicons-react` (`file-icon.tsx`). + `ProviderIcon.tsx`, file-type logos from `devicons-react` (`FileIcon.tsx`). ## Interaction states diff --git a/frontend/biome.jsonc b/frontend/biome.jsonc new file mode 100644 index 000000000..ab4e6791e --- /dev/null +++ b/frontend/biome.jsonc @@ -0,0 +1,65 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json", + "files": { + "includes": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "vitest.config.ts"] + }, + // The style the codebase already mostly held, so adopting the tool is a + // reflow, not a restyle: 2-space indent, double quotes, no semicolons. + // lineWidth 100 measured as the least-churn width over the existing source. + "formatter": { + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "semicolons": "asNeeded", + "quoteStyle": "double", + "jsxQuoteStyle": "double", + "arrowParentheses": "always", + "trailingCommas": "all", + "bracketSpacing": true + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + // Narrow dependency lists are the app's pattern, not an oversight: the + // git-status poll drives invalidation off status counts, gates run once + // per launch, and the latest project/session is read through a ref. + // Every hit of this rule so far has been deliberate. + "useExhaustiveDependencies": "off" + }, + "complexity": { + // `!x || !x.y` states the guard; `!x?.y` folds two conditions into one + // read. Equivalent, and the explicit form is the one kept here. + "useOptionalChain": "off" + }, + "a11y": { + // Reported, not enforced: the fixes are DOM changes, and the offenders + // are the duplicated close-affordance and resize handle that the + // component extraction collapses into one place to fix. + "useFocusableInteractive": "warn", + "useKeyWithClickEvents": "warn", + "useSemanticElements": "warn", + "useAriaPropsForRole": "warn", + "useAriaPropsSupportedByRole": "warn" + } + } + }, + // Import order is an opinion of its own; keep it out of the format pass. + "assist": { + "actions": { "source": { "organizeImports": "off" } } + }, + "overrides": [ + { + // shadcn primitives are CLI output adapted to DESIGN.md, not hand-written + // (see CLAUDE.md). Their `import * as React` is what the generator emits; + // rewriting it only makes the next `shadcn add` diverge. + "includes": ["src/components/ui/**"], + "linter": { "rules": { "style": { "useImportType": "off" } } } + } + ] +} diff --git a/frontend/package.json b/frontend/package.json index bc12b96bc..ffb7624a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "react-ts-latest", + "name": "lich-frontend", "private": true, "version": "0.0.0", "type": "module", @@ -9,7 +9,9 @@ "build:dev": "tsc && vite build --minify false --mode development", "build": "tsc && vite build --mode production", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "check": "biome check .", + "format": "biome check --write ." }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -42,6 +44,7 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@biomejs/biome": "^2.5.5", "@tailwindcss/vite": "^4.3.2", "@types/node": "^26.1.0", "@types/react": "^18.2.43", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c4fe8020c..dc5096ca6 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: specifier: ^1.4.0 version: 1.4.0 devDependencies: + '@biomejs/biome': + specifier: ^2.5.5 + version: 2.5.5 '@tailwindcss/vite': specifier: ^4.3.2 version: 4.3.2(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) @@ -290,6 +293,63 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@biomejs/biome@2.5.5': + resolution: {integrity: sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.5': + resolution: {integrity: sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.5': + resolution: {integrity: sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.5': + resolution: {integrity: sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.5': + resolution: {integrity: sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.5': + resolution: {integrity: sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.5': + resolution: {integrity: sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.5': + resolution: {integrity: sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.5': + resolution: {integrity: sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} @@ -2769,6 +2829,41 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.5.5': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.5 + '@biomejs/cli-darwin-x64': 2.5.5 + '@biomejs/cli-linux-arm64': 2.5.5 + '@biomejs/cli-linux-arm64-musl': 2.5.5 + '@biomejs/cli-linux-x64': 2.5.5 + '@biomejs/cli-linux-x64-musl': 2.5.5 + '@biomejs/cli-win32-arm64': 2.5.5 + '@biomejs/cli-win32-x64': 2.5.5 + + '@biomejs/cli-darwin-arm64@2.5.5': + optional: true + + '@biomejs/cli-darwin-x64@2.5.5': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.5': + optional: true + + '@biomejs/cli-linux-arm64@2.5.5': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.5': + optional: true + + '@biomejs/cli-linux-x64@2.5.5': + optional: true + + '@biomejs/cli-win32-arm64@2.5.5': + optional: true + + '@biomejs/cli-win32-x64@2.5.5': + optional: true + '@codemirror/autocomplete@6.20.3': dependencies: '@codemirror/language': 6.12.4 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3394018b7..c6dc82786 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useState } from "react" import { HashRouter, Outlet, Route, Routes } from "react-router-dom" -import { SettingsProvider } from "@/lib/settings" -import { ProjectsProvider } from "@/lib/projects" +import { SettingsProvider } from "@/providers/settings" +import { ProjectsProvider } from "@/providers/projects" import { ProjectTabs } from "@/components/tabs/ProjectTabs" import { SessionSidebar } from "@/components/sidebar/SessionSidebar" import { TerminalHost } from "@/components/TerminalHost" @@ -22,8 +22,7 @@ import { CommandPalette } from "@/components/CommandPalette" // top of the terminals. function Layout() { const [dock, setDock] = useState(null) - const toggleDock = (tab: DockTab) => - setDock((cur) => (cur === tab ? null : tab)) + const toggleDock = (tab: DockTab) => setDock((cur) => (cur === tab ? null : tab)) return (
@@ -36,13 +35,7 @@ function Layout() {
- {dock && ( - setDock(null)} - /> - )} + {dock && setDock(null)} />} diff --git a/frontend/src/components/AppUpdateGate.tsx b/frontend/src/components/AppUpdateGate.tsx index ee23ac993..78d7c6f28 100644 --- a/frontend/src/components/AppUpdateGate.tsx +++ b/frontend/src/components/AppUpdateGate.tsx @@ -1,13 +1,17 @@ -import {useEffect, useRef} from "react" -import {toast} from "sonner" -import {useMatch, useNavigate} from "react-router-dom" -import {Button} from "@/components/ui/button" -import {decideUpdateAction, UPDATE_DISMISSED_KEY, type UpdateAction} from "@/lib/app-update-gate" -import {AppUpdate, System} from "@/lib/rpc" -import {useProjects} from "@/lib/projects" -import {queuePaste} from "@/lib/paste-queue" -import {registerUpdateChecker} from "@/lib/update-check" -import {errorText} from "@/lib/utils" +import { useEffect, useRef } from "react" +import { toast } from "sonner" +import { useMatch, useNavigate } from "react-router-dom" +import { Button } from "@/components/ui/button" +import { + decideUpdateAction, + UPDATE_DISMISSED_KEY, + type UpdateAction, +} from "@/lib/update/app-update-gate" +import { AppUpdate, System } from "@/lib/rpc" +import { useProjects } from "@/providers/projects" +import { queuePaste } from "@/lib/terminal/paste-queue" +import { registerUpdateChecker } from "@/lib/update/update-check" +import { errorText } from "@/lib/utils" // How often to re-check for a release after startup, so a long-running session // eventually notices one. Hourly is plenty — releases are rare and the @@ -20,7 +24,7 @@ const POLL_INTERVAL_MS = 60 * 60 * 1000 // paste the install command into a terminal (the user runs it) or open the // release page. Any failure is silent — it must never block or break startup. export function AppUpdateGate() { - const {newSession, ensureHomeProject} = useProjects() + const { newSession, ensureHomeProject } = useProjects() const navigate = useNavigate() const activeProjectId = useMatch("/projects/:projectId")?.params.projectId ?? null @@ -43,7 +47,7 @@ export function AppUpdateGate() { return () => registerUpdateChecker(null) }) - const prompt = (action: UpdateAction & {kind: "update"}) => { + const prompt = (action: UpdateAction & { kind: "update" }) => { promptedVersion.current = action.version if (action.canSelfApply) { promptSelfApply(action.version) @@ -81,8 +85,8 @@ export function AppUpdateGate() { const promptSelfApply = (version: string) => { toast(`lich ${version} is available`, { duration: Infinity, - action: {label: "Update & install", onClick: () => void runApply()}, - cancel: {label: "Later", onClick: () => dismiss(version)}, + action: { label: "Update & install", onClick: () => void runApply() }, + cancel: { label: "Later", onClick: () => dismiss(version) }, }) } @@ -94,13 +98,13 @@ export function AppUpdateGate() { try { await AppUpdate.Apply() } catch (error) { - toast.error(`Update failed: ${errorText(error)}`, {id}) + toast.error(`Update failed: ${errorText(error)}`, { id }) return } toast.success("lich updated", { id, duration: Infinity, - action: {label: "Restart", onClick: () => void runRestart()}, + action: { label: "Restart", onClick: () => void runRestart() }, }) } @@ -155,7 +159,7 @@ export function AppUpdateGate() { ), - {duration: Infinity}, + { duration: Infinity }, ) } diff --git a/frontend/src/components/ClaudePluginGate.tsx b/frontend/src/components/ClaudePluginGate.tsx index 945552e41..f7aaa4010 100644 --- a/frontend/src/components/ClaudePluginGate.tsx +++ b/frontend/src/components/ClaudePluginGate.tsx @@ -1,6 +1,6 @@ -import {useEffect, useRef, useState} from "react" -import {toast} from "sonner" -import {Button} from "@/components/ui/button" +import { useEffect, useRef, useState } from "react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" import { Dialog, DialogContent, @@ -13,13 +13,13 @@ import { decidePluginAction, DISMISSED_FLAG, INSTALL_DISMISSED_KEY, + type PluginAction, + RESTART_HINT, UPDATE_DISMISSED_KEY, -} from "@/lib/plugin-gate" -import {ClaudePlugin} from "@/lib/rpc" -import {errorText} from "@/lib/utils" -import {runWithToast} from "@/lib/toast-async" - -const RESTART_HINT = "restart your Claude sessions to apply." +} from "@/lib/update/plugin-gate" +import { ClaudePlugin } from "@/lib/rpc" +import { errorText } from "@/lib/utils" +import { runWithToast } from "@/lib/toast-async" // ClaudePluginGate checks on startup whether the lich Claude Code plugin is // installed and current. Missing → a one-click install modal; a newer release → @@ -39,7 +39,7 @@ export function ClaudePluginGate() { }, []) const check = async () => { - let action + let action: PluginAction try { const status = await ClaudePlugin.Status() action = decidePluginAction( @@ -60,7 +60,7 @@ export function ClaudePluginGate() { const promptUpdate = (version: string) => { toast(`lich plugin ${version} is available`, { duration: Infinity, - action: {label: "Update", onClick: () => void runUpdate()}, + action: { label: "Update", onClick: () => void runUpdate() }, cancel: { label: "Later", onClick: () => localStorage.setItem(UPDATE_DISMISSED_KEY, version), @@ -95,14 +95,17 @@ export function ClaudePluginGate() { } return ( - !open && !installing && setInstallOpen(false)}> + !open && !installing && setInstallOpen(false)} + > Enable the Claude Code integration - Install the lich plugin for Claude Code to get the most out of lich. - It deepens the integration between your sessions and the app, and gains - new capabilities with each release. + Install the lich plugin for Claude Code to get the most out of lich. It deepens the + integration between your sessions and the app, and gains new capabilities with each + release. diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 57f17a738..24fdc926e 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -2,12 +2,12 @@ import { useEffect, useMemo, useRef, useState } from "react" import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { useNavigate } from "react-router-dom" import { CornerDownLeft, Folder, Search } from "lucide-react" -import { useProjects } from "@/lib/projects" -import { useSettings } from "@/lib/settings" +import { useProjects } from "@/providers/projects" +import { useSettings } from "@/providers/settings" import { isRecordingTarget, matchesCombo } from "@/lib/hotkeys" -import { useSessionStatus } from "@/lib/useSessionStatus" +import { useSessionStatus } from "@/lib/session/use-session-status" import { SessionStatusIcon } from "@/components/sidebar/SessionStatusIcon" -import { filterPalette, paletteSessions, type PaletteSession } from "@/lib/command-palette" +import { filterPalette, paletteSessions, type PaletteSession } from "@/lib/session/command-palette" import type { Project } from "@/lib/api-types" import { cn } from "@/lib/utils" @@ -103,6 +103,7 @@ export function CommandPalette() {
setQuery(e.target.value)} @@ -197,7 +198,10 @@ function Row({ selected, onSelect, onRun, children }: RowProps) { > {children} ) diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx index ad4b379a6..f78d733fe 100644 --- a/frontend/src/components/ConfirmDialog.tsx +++ b/frontend/src/components/ConfirmDialog.tsx @@ -38,9 +38,7 @@ export function ConfirmDialog({ {title} - - {description} - + {description} -
+ ) } diff --git a/frontend/src/lib/file-icon.tsx b/frontend/src/components/FileIcon.tsx similarity index 67% rename from frontend/src/lib/file-icon.tsx rename to frontend/src/components/FileIcon.tsx index 2602fd017..2061466b3 100644 --- a/frontend/src/lib/file-icon.tsx +++ b/frontend/src/components/FileIcon.tsx @@ -1,5 +1,5 @@ -import type {ComponentType} from "react" -import {File} from "lucide-react" +import type { ComponentType } from "react" +import { File } from "lucide-react" // Deep per-icon imports, NOT the devicons-react barrel: the barrel is one CJS // module re-exporting every icon, so importing from it bundles all ~4000 of // them (~10MB). The package's exports map allows ./icons/*, so each icon is its @@ -17,7 +17,7 @@ import RustOriginal from "devicons-react/icons/RustOriginal" import TypescriptOriginal from "devicons-react/icons/TypescriptOriginal" import XmlOriginal from "devicons-react/icons/XmlOriginal" import YamlOriginal from "devicons-react/icons/YamlOriginal" -import {extname} from "./lang-badge" +import { extname } from "@/lib/git/lang-badge" // devicons-react components spread extra props onto their and take a // `size` that sets both dimensions. Only the props this module passes are @@ -32,24 +32,24 @@ type IconComponent = ComponentType<{ // with no fill of their own (they render black and vanish on a dark ground), so // those ride the row's text color via fill=currentColor; every other icon keeps // its brand colors. Extensions absent here fall back to a neutral file glyph. -const ICONS: Record = { - ts: {Icon: TypescriptOriginal}, - tsx: {Icon: TypescriptOriginal}, - js: {Icon: JavascriptOriginal}, - jsx: {Icon: JavascriptOriginal}, - go: {Icon: GoOriginal}, - css: {Icon: Css3Original}, - html: {Icon: Html5Original}, - json: {Icon: JsonOriginal}, - kt: {Icon: KotlinOriginal}, - kts: {Icon: KotlinOriginal}, - md: {Icon: MarkdownOriginal, mono: true}, - xml: {Icon: XmlOriginal}, - yaml: {Icon: YamlOriginal}, - yml: {Icon: YamlOriginal}, - sh: {Icon: BashOriginal}, - py: {Icon: PythonOriginal}, - rs: {Icon: RustOriginal, mono: true}, +const ICONS: Record = { + ts: { Icon: TypescriptOriginal }, + tsx: { Icon: TypescriptOriginal }, + js: { Icon: JavascriptOriginal }, + jsx: { Icon: JavascriptOriginal }, + go: { Icon: GoOriginal }, + css: { Icon: Css3Original }, + html: { Icon: Html5Original }, + json: { Icon: JsonOriginal }, + kt: { Icon: KotlinOriginal }, + kts: { Icon: KotlinOriginal }, + md: { Icon: MarkdownOriginal, mono: true }, + xml: { Icon: XmlOriginal }, + yaml: { Icon: YamlOriginal }, + yml: { Icon: YamlOriginal }, + sh: { Icon: BashOriginal }, + py: { Icon: PythonOriginal }, + rs: { Icon: RustOriginal, mono: true }, } // ICON_PX matches the tree's size-3.5 (14px) folder icons so rows line up. @@ -59,17 +59,11 @@ interface FileIconProps { path: string } -export function FileIcon({path}: FileIconProps) { +export function FileIcon({ path }: FileIconProps) { const def = ICONS[extname(path)] if (!def) { - return + return } - const {Icon, mono} = def - return ( - - ) + const { Icon, mono } = def + return } diff --git a/frontend/src/components/FileTree.tsx b/frontend/src/components/FileTree.tsx index 3139113cb..84873c2e4 100644 --- a/frontend/src/components/FileTree.tsx +++ b/frontend/src/components/FileTree.tsx @@ -1,10 +1,10 @@ -import {useState} from "react" -import {ChevronDown, ChevronRight, Folder, FolderOpen} from "lucide-react" -import type {TreeNode} from "@/lib/file-tree" -import type {DiffFile} from "@/lib/diff" -import {FileIcon} from "@/lib/file-icon" -import {DiffStat} from "@/components/DiffStat" -import {cn} from "@/lib/utils" +import { useState } from "react" +import { ChevronDown, ChevronRight, Folder, FolderOpen } from "lucide-react" +import type { TreeNode } from "@/lib/git/file-tree" +import type { DiffFile } from "@/lib/git/diff" +import { FileIcon } from "./FileIcon" +import { DiffStat } from "./DiffStat" +import { cn } from "@/lib/utils" import { ContextMenu, ContextMenuContent, @@ -120,19 +120,19 @@ function TreeRow({ onSelect, }: TreeRowProps) { // The 0.5rem base keeps even top-level rows off the edge. - const indent = {paddingLeft: `${depth * 0.75 + 0.5}rem`} + const indent = { paddingLeft: `${depth * 0.75 + 0.5}rem` } if (node.type === "file") { const stat = stats?.get(node.path) // A chevron-width spacer keeps file names aligned under their folder's name; // FileIcon draws the language's real logo (devicon). const row = ( <> - - + + {node.name} {stat && ( - + )} @@ -170,9 +170,7 @@ function TreeRow({ {row} - onEditor(node.path)}> - Open in editor - + onEditor(node.path)}>Open in editor ) @@ -194,17 +192,13 @@ function TreeRow({ /> } > - - + + {node.name} - onSubtree(node, true)}> - Expand all - - onSubtree(node, false)}> - Collapse all - + onSubtree(node, true)}>Expand all + onSubtree(node, false)}>Collapse all {open && diff --git a/frontend/src/components/FooterBar.tsx b/frontend/src/components/FooterBar.tsx index 4150ab895..317f1afe4 100644 --- a/frontend/src/components/FooterBar.tsx +++ b/frontend/src/components/FooterBar.tsx @@ -1,27 +1,23 @@ -import {useEffect, useState} from "react" -import {useNavigate} from "react-router-dom" -import {openPulls} from "@/lib/pulls-card-store" -import {toast} from "sonner" -import {Code, FileText, GitBranch, Folder, Plus, Diff, GitPullRequestArrow} from "lucide-react" -import {ProjectService, Terminal as TerminalService} from "@/lib/rpc" -import type {DockTab} from "@/components/dock/RightDock" -import {useActiveSession} from "@/lib/useActiveSession" -import {useSessionCwd} from "@/lib/useSessionCwd" -import {useSessionUsage} from "@/lib/useSessionUsage" -import {displayPath} from "@/lib/paths" -import {useGitStatus} from "@/lib/useGitStatus" -import {usePullRequest} from "@/lib/usePullRequest" -import {useSettings} from "@/lib/settings" -import {cn} from "@/lib/utils" -import {DiffStat} from "@/components/DiffStat" -import {ContextRing, contextColor} from "@/components/ContextRing" -import {SessionModel} from "@/components/SessionModel" -import {Separator} from "@/components/ui/separator" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" +import { useEffect, useState } from "react" +import { useNavigate } from "react-router-dom" +import { openPulls } from "@/lib/pulls-card-store" +import { toast } from "sonner" +import { Code, FileText, GitBranch, Folder, Plus, Diff, GitPullRequestArrow } from "lucide-react" +import { ProjectService, Terminal as TerminalService } from "@/lib/rpc" +import type { DockTab } from "@/components/dock/RightDock" +import { useActiveSession } from "@/lib/session/use-active-session" +import { useSessionCwd } from "@/lib/session/use-session-cwd" +import { useSessionUsage } from "@/lib/session/use-session-usage" +import { displayPath } from "@/lib/paths" +import { useGitStatus } from "@/lib/git/use-git-status" +import { usePullRequest } from "@/lib/pulls/use-pull-request" +import { useSettings } from "@/providers/settings" +import { cn } from "@/lib/utils" +import { DiffStat } from "./DiffStat" +import { ContextRing, contextColor } from "./ContextRing" +import { SessionModel } from "./SessionModel" +import { Separator } from "@/components/ui/separator" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" const CLOCK_TICK_MS = 10_000 @@ -44,9 +40,9 @@ interface FooterBarProps { // FooterBar is the Warp-style status strip. Git segments only render while a // project is active; everything follows the active session — a worktree session // shows its checkout's path, branch and diff. -export function FooterBar({dock, onDock}: FooterBarProps) { +export function FooterBar({ dock, onDock }: FooterBarProps) { const navigate = useNavigate() - const {projectId, sessionId, path: basePath} = useActiveSession() + const { projectId, sessionId, path: basePath } = useActiveSession() // Overlay the backend's live cwd so a `cd` in the terminal moves the footer // with it — same source the session card follows. Falls back to the session's // static start path until the watcher reports. @@ -55,7 +51,7 @@ export function FooterBar({dock, onDock}: FooterBarProps) { // each turn's end (null until the first turn of a Claude session lands). const usage = useSessionUsage(sessionId) // The footer context readout is opt-out (Settings › Providers). - const {showContextUsage} = useSettings() + const { showContextUsage } = useSettings() const status = useGitStatus(path) const pr = usePullRequest(path, status?.branch ?? "", status?.head ?? "") const now = useNow() @@ -73,47 +69,41 @@ export function FooterBar({dock, onDock}: FooterBarProps) { // The context-window readout — the ring plus percent, with a detailed // tooltip. Null when the user turned it off (Settings › Providers). - const contextReadout = usage && showContextUsage ? ( - - - } - > - - {usage.percent}% - - -
- Context window -
- - + const contextReadout = + usage && showContextUsage ? ( + + + } + > + + {usage.percent}% + + +
+ Context window +
+ + + + {usage.percent}% +
+ + {usage.tokens.toLocaleString()} / {usage.window.toLocaleString()} tokens - {usage.percent}%
- - {usage.tokens.toLocaleString()} / {usage.window.toLocaleString()} tokens - -
- - - ) : null + + + ) : null return ( -
@@ -230,36 +208,24 @@ interface PreviewBodyProps { // PreviewBody renders the file in a read-only CodeMirror view whose selection // drives the same inject context menu as the diff review — file lines map // straight through (doc line === file line), so the range needs no remap. -function PreviewBody({text, rel, onInject}: PreviewBodyProps) { - const {containerRef, getSelectedLines} = useFileEditor(text, rel) - const [range, setRange] = useState(null) +function PreviewBody({ text, rel, onInject }: PreviewBodyProps) { + const { containerRef, getSelectedLines } = useFileEditor(text, rel) + const [lineRef, setLineRef] = useState(null) - // Resolve the selection when the menu opens, not on every selection change. - const onOpenChange = (menuOpen: boolean) => { - if (menuOpen) { - setRange(getSelectedLines()) - } - } - - const lineRef = range && formatLineRef({start: range.from, end: range.to}) return ( - - }/> - - onInject(`@${rel} `)}> - Inject file - - lineRef && onInject(`${rel}:${lineRef} `)} - > - {lineRef === null ? "Inject lines" : `Inject lines ${lineRef}`} - - - + { + if (!open) { + return + } + const range = getSelectedLines() + setLineRef(range && formatLineRef({ start: range.from, end: range.to })) + }} + onInject={onInject} + /> ) } - -function Notice({children}: {children: string}) { - return

{children}

-} diff --git a/frontend/src/components/dock/RightDock.tsx b/frontend/src/components/dock/RightDock.tsx index 14d78d77d..c17697e67 100644 --- a/frontend/src/components/dock/RightDock.tsx +++ b/frontend/src/components/dock/RightDock.tsx @@ -1,17 +1,23 @@ -import {useState} from "react" -import {ChevronsDownUp, ChevronsUpDown, Code, FileDiff, Maximize2, Minimize2, X} from "lucide-react" -import {Button} from "@/components/ui/button" -import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs" -import {DiffStat} from "@/components/DiffStat" -import {HeaderAction} from "@/components/diff/FileDiff" -import {ReviewPanel} from "@/components/diff/ReviewPanel" -import {useActiveSession} from "@/lib/useActiveSession" -import {useGitStatus} from "@/lib/useGitStatus" -import {usePanelWidth} from "@/lib/use-panel-width" -import {FilesPanel} from "./FilesPanel" +import { Code, FileDiff, Maximize2, Minimize2, X } from "lucide-react" +import { useState } from "react" +import { IconAction } from "@/components/common/IconAction" +import { ResizeHandle } from "@/components/common/ResizeHandle" +import { CollapseAllAction, useDiffBulk } from "@/components/diff/diff-bulk" +import { ReviewPanel } from "@/components/diff/ReviewPanel" +import { DiffStat } from "@/components/DiffStat" +import { Button } from "@/components/ui/button" +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { usePanelWidth } from "@/lib/use-panel-width" +import { useActiveSession } from "@/lib/session/use-active-session" +import { useGitStatus } from "@/lib/git/use-git-status" +import { FilesPanel } from "./FilesPanel" export type DockTab = "files" | "review" +// Ghost tabs, sized down to the dock's 40px header — not the stock filled pill. +const TAB_CLASS = + "gap-1 rounded-md px-2 py-0.5 text-xs hover:bg-accent/50 data-active:bg-accent data-active:text-accent-foreground dark:data-active:border-transparent dark:data-active:bg-accent dark:data-active:text-accent-foreground" + interface RightDockProps { tab: DockTab onTab: (tab: DockTab) => void @@ -23,16 +29,12 @@ interface RightDockProps { // (persisted), full-screen toggle, the tab bar and the close button — so the // two panels stay pure bodies. One dock width serves both tabs: it is a single // panel that swaps contents, not two panels competing for the edge. -export function RightDock({tab, onTab, onClose}: RightDockProps) { - const {path} = useActiveSession() +export function RightDock({ tab, onTab, onClose }: RightDockProps) { + const { path } = useActiveSession() const status = useGitStatus(path) const [fullscreen, setFullscreen] = useState(false) - // One collapse/expand-all directive for the review panel; the nonce re-fires - // the sync even when every file already holds the target state. - const [bulk, setBulk] = useState({open: true, nonce: 0}) - const toggleAll = () => - setBulk((b) => ({open: !b.open, nonce: b.nonce + 1})) - const {width, handleProps} = usePanelWidth({ + const [bulk, toggleAll] = useDiffBulk() + const { width, handleProps } = usePanelWidth({ storageKey: "lich.dock.width", minRem: 20, maxRem: 60, @@ -48,47 +50,34 @@ export function RightDock({tab, onTab, onClose}: RightDockProps) { ? "absolute inset-0 z-20 flex flex-col bg-sidebar" : "relative flex shrink-0 flex-col border-l border-border bg-sidebar" } - style={fullscreen ? undefined : {width: `${width}rem`}} + style={fullscreen ? undefined : { width: `${width}rem` }} >
onTab(value as DockTab)} className={"h-8"}> - - + + Code - - + + Review {status && status.files > 0 && ( - + )} {tab === "review" && status && status.files > 0 && ( - - {bulk.open ? ( - - ) : ( - - )} - + )} - setFullscreen((v) => !v)} > - {fullscreen ? ( - - ) : ( - - )} - + {fullscreen ? : } +
- {tab === "files" ? : } + {tab === "files" ? : }
- {!fullscreen && ( -
- )} + {!fullscreen && } ) } diff --git a/frontend/src/components/dock/useFileEditor.ts b/frontend/src/components/dock/useFileEditor.ts index e50a09037..b7fad282e 100644 --- a/frontend/src/components/dock/useFileEditor.ts +++ b/frontend/src/components/dock/useFileEditor.ts @@ -1,7 +1,7 @@ -import {useEffect, useRef} from "react" -import type {RefObject} from "react" -import {EditorState} from "@codemirror/state" -import {EditorView, lineNumbers} from "@codemirror/view" +import { useEffect, useRef } from "react" +import type { RefObject } from "react" +import { EditorState } from "@codemirror/state" +import { EditorView, lineNumbers } from "@codemirror/view" import { loadLanguage, readOnlyCodeExtensions, @@ -47,5 +47,5 @@ export function useFileEditor(text: string, filename: string): FileEditor { const getSelectedLines = (): DocLineSelection | null => viewRef.current ? selectedDocLines(viewRef.current) : null - return {containerRef, getSelectedLines} + return { containerRef, getSelectedLines } } diff --git a/frontend/src/components/pulls/Pulls.tsx b/frontend/src/components/pulls/Pulls.tsx index 54797cf99..2b522a69b 100644 --- a/frontend/src/components/pulls/Pulls.tsx +++ b/frontend/src/components/pulls/Pulls.tsx @@ -1,6 +1,6 @@ -import {useState, type ReactNode} from "react" -import {useNavigate, useParams} from "react-router-dom" -import {toast} from "sonner" +import { useState, type ReactNode } from "react" +import { useNavigate, useParams } from "react-router-dom" +import { toast } from "sonner" import { Check, ChevronDown, @@ -14,17 +14,19 @@ import { X, type LucideIcon, } from "lucide-react" -import {ProjectService, Store, System, Terminal as TerminalService} from "@/lib/rpc" -import type {ChecksRollup, MergeMethod, PullRequestDetail} from "@/lib/api-types" -import {useProjects} from "@/lib/projects" -import {baseName} from "@/lib/paths" -import {closePulls} from "@/lib/pulls-card-store" -import {activeTarget, sessionsOf} from "@/lib/sessions" -import {useGitStatus} from "@/lib/useGitStatus" -import {usePullRequestDetail} from "@/lib/usePullRequestDetail" -import {cn, errorText} from "@/lib/utils" -import {Markdown} from "@/components/Markdown" -import {Button} from "@/components/ui/button" +import { ProjectService, Store, System } from "@/lib/rpc" +import type { ChecksRollup, MergeMethod, PullRequestDetail } from "@/lib/api-types" +import { useProjects } from "@/providers/projects" +import { baseName } from "@/lib/paths" +import { Notice } from "@/components/common/Notice" +import { closePulls } from "@/lib/pulls-card-store" +import { activeTarget, sessionsOf } from "@/lib/session/sessions" +import { useGitStatus } from "@/lib/git/use-git-status" +import { usePullRequestDetail } from "@/lib/pulls/use-pull-request-detail" +import { useInject } from "@/lib/use-inject" +import { cn, errorText } from "@/lib/utils" +import { Markdown } from "@/components/Markdown" +import { Button } from "@/components/ui/button" import { Dialog, DialogContent, @@ -40,10 +42,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import {Input} from "@/components/ui/input" -import {Label} from "@/components/ui/label" -import {PullsChecks} from "./PullsChecks" -import {PullsFiles} from "./PullsFiles" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { PullsChecks } from "./PullsChecks" +import { PullsFiles } from "./PullsFiles" // Pulls is the per-project pull-request screen: it fills the main area on top of // the persistent terminals (like Settings), showing the active session's branch @@ -51,21 +53,16 @@ import {PullsFiles} from "./PullsFiles" // resolves its path from the route's project id plus the active session (the // exact-match useActiveSession returns empty on this subroute). export function Pulls() { - const {projectId} = useParams() + const { projectId } = useParams() const navigate = useNavigate() - const {projects, sessions, closeSession} = useProjects() + const { projects, sessions, closeSession } = useProjects() const projectPath = projects.find((p) => p.id === projectId)?.path ?? "" - const {sessionId, path} = activeTarget(sessions, projectId ?? null, projectPath) + const { sessionId, path } = activeTarget(sessions, projectId ?? null, projectPath) const status = useGitStatus(path) const branch = status?.branch ?? "" const head = status?.head ?? "" - const {detail, loading, error, refresh} = usePullRequestDetail(path, branch, head) - - const inject = (text: string) => { - if (sessionId) { - void TerminalService.Write(sessionId, text) - } - } + const { detail, loading, error, refresh } = usePullRequestDetail(path, branch, head) + const inject = useInject(sessionId) // A merged branch leaves its checkout behind — the one cleanup the user would // otherwise walk back to the sidebar for. Refuse a dirty worktree: whatever @@ -106,15 +103,15 @@ export function Pulls() { } toast.success(merged, { duration: 10_000, - action: {label: "Remove worktree", onClick: () => void removeWorktree()}, + action: { label: "Remove worktree", onClick: () => void removeWorktree() }, }) } let body: ReactNode if (!path) { - body = No repository + body = No repository } else if (error) { - body = Couldn’t load the pull request: {error} + body = Couldn’t load the pull request: {error} } else if (detail) { body = ( ) } else if (loading) { - body = Loading… + body = Loading… } else { - body = + body = } return
{body}
@@ -156,7 +153,14 @@ interface PullRequestViewProps { onInject: (text: string) => void } -function PullRequestView({path, head, detail, onRefresh, onMerged, onInject}: PullRequestViewProps) { +function PullRequestView({ + path, + head, + detail, + onRefresh, + onMerged, + onInject, +}: PullRequestViewProps) { const [merging, setMerging] = useState(false) const [edit, setEdit] = useState(null) const [tab, setTab] = useState<"overview" | "files" | "checks">("overview") @@ -212,9 +216,9 @@ function PullRequestView({path, head, detail, onRefresh, onMerged, onInject}: Pu - + {merging ? "Merging…" : "Merge"} - + } /> @@ -228,7 +232,7 @@ function PullRequestView({path, head, detail, onRefresh, onMerged, onInject}: Pu void merge("rebase")}> Rebase and merge - + openEdit("squash")}> Squash and merge, edit message… @@ -241,10 +245,10 @@ function PullRequestView({path, head, detail, onRefresh, onMerged, onInject}: Pu {/* HEAD moving refetches on its own; this covers what it can't see — a review, a check finishing, a PR opened from the terminal. */}
@@ -257,11 +261,11 @@ function PullRequestView({path, head, detail, onRefresh, onMerged, onInject}: Pu detail.isDraft ? "text-amber-500" : "text-emerald-500", )} > - + {detail.isDraft ? "Draft" : "Open"}
- + {detail.headRefName} → {detail.baseRefName} {/* The counter is where the eye lands when CI is red; make it the way @@ -272,12 +276,12 @@ function PullRequestView({path, head, detail, onRefresh, onMerged, onInject}: Pu onClick={() => setTab("checks")} className="rounded-sm transition-opacity hover:opacity-80" > - + ) : ( - + )} - +
@@ -301,11 +305,11 @@ function PullRequestView({path, head, detail, onRefresh, onMerged, onInject}: Pu
{tab === "files" ? ( - + ) : (
{tab === "checks" ? ( - + ) : (
{detail.body.trim() !== "" ? ( @@ -332,7 +336,15 @@ function PullRequestView({path, head, detail, onRefresh, onMerged, onInject}: Pu ) } -function TabButton({active, onClick, children}: {active: boolean; onClick: () => void; children: ReactNode}) { +function TabButton({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: ReactNode +}) { return (
@@ -382,7 +400,7 @@ function MergeMessageDialog({edit, merging, onChange, onCancel, onConfirm}: Merg