diff --git a/apps/desktop/docs/MEMORY_AND_WORKERS_REVIEW.md b/apps/desktop/docs/MEMORY_AND_WORKERS_REVIEW.md index 36251df4073..1614306b4ea 100644 --- a/apps/desktop/docs/MEMORY_AND_WORKERS_REVIEW.md +++ b/apps/desktop/docs/MEMORY_AND_WORKERS_REVIEW.md @@ -1,27 +1,29 @@ # Memory Profile & Worker Offload Review -2026-07-18. Code sweep across renderer / Electron main / host-service / pty-daemon, then every load-bearing claim verified against the live dev app over CDP (18-terminal stress, heap + RSS sampling, process-table spawn sampling). Claims marked **CDP** were observed at runtime; **code** means verified at the cited line but not behaviorally driven. +Initial review 2026-07-18; progress and the large-repository/Pierre reruns updated 2026-07-19. Code sweep across renderer / Electron main / host-service / pty-daemon, then every load-bearing claim verified against the live dev app over CDP (18-terminal stress, heap + RSS sampling, process-table spawn sampling). Claims marked **CDP** were observed at runtime; **code** means verified at the cited line but not behaviorally driven. ## Progress | Item | Ticket | Status | |---|---|---| -| Parked-terminal LRU eviction (terminal half) | SUPER-1545 | **Shipped** in PR #5751. Cap defaults to 12 parked, user-configurable (Settings → Terminal → "Background terminal memory"; local-db `terminal_parked_runtime_cap`, migration 0042, zod-clamped 2–64, live-applied via `terminalRuntimeRegistry.setParkedRuntimeCap`). CDP-verified: default seeds at boot, 16-tab cycle parks exactly 12, lowering to 6 sweeps immediately, persists, out-of-range rejected. Two controlled A/Bs below (18-terminal fill, 24-terminal live-stream; both measured at cap 5 — savings scale down as the cap rises) | -| Parked-webview eviction + alt-screen exemption + quota guard | SUPER-1545 | **Implemented** (hidden-webview LRU cap 3 in `browserRuntimeRegistry`; alternate-screen TUIs exempt from terminal eviction; eviction skipped when the buffer cannot persist). CDP-verified 2026-07-18 | +| Parked-terminal LRU eviction (terminal half) | SUPER-1545 | **Shipped** in PR #5751. Cap defaults to 12 parked, user-configurable (Settings → Terminal → "Background terminal memory"; local-db `terminal_parked_runtime_cap`, migration 0042, zod-clamped 2–64, live-applied via `terminalRuntimeRegistry.setParkedRuntimeCap`). Workspace-close disposal was hardened in PR #5752 and eviction persistence races in PR #5771. CDP-verified: default seeds at boot, 16-tab cycle parks exactly 12, lowering to 6 sweeps immediately, persists, out-of-range rejected. Two controlled A/Bs below (18-terminal fill, 24-terminal live-stream; both measured at cap 5 — savings scale down as the cap rises) | +| Parked-webview eviction + alt-screen exemption + quota guard | SUPER-1545 | **Shipped** in PR #5754 (hidden-webview LRU cap 3 in `browserRuntimeRegistry`; alternate-screen TUIs exempt from terminal eviction; eviction skipped when the buffer cannot persist). CDP-verified 2026-07-18 | | Chat shiki off main thread | SUPER-1546 | Not started | -| Collections preload/window/evict | SUPER-1547 | Not started | +| Collections preload/window/evict | SUPER-1547 | Org-switch eviction **shipped** in PR #5778; preload deferral and table windowing are not started | | Async process-tree probes | SUPER-1548 | Not started | | Search pinned worker | SUPER-1549 | Not started (measurement-gated on SUPER-1544) | -| Memory telemetry | SUPER-1550 | Not started | -| Host-service git worker pool | SUPER-1544 | Planned in boundless-swan worktree (companion plan) | +| Memory telemetry | SUPER-1550 | **Shipped** in PR #5777. Production-only, privacy-allowlisted `resource_snapshot` every 5–6 minutes; Electron process-class RSS/counts plus main-process heap/RSS, uptime, window count, and web-contents count | +| Host-service git worker pool | SUPER-1544 | **Shipped** in PR #5750; watcher batches bounded in PR #5746. Git status and commit-file work run in the generic `worker_threads` pool; base-ref fetch coordination shipped in PR #5776 | +| Large Changes-list renderer churn | — | **Fix measured; PR #5782 is ready for review.** Latest-main reproduction identified eager `FileRow` mounting as the renderer hot path. Both folder and tree modes now use one bounded `@pierre/trees` renderer; the direct Pierre dependencies were upgraded to `@pierre/diffs` 1.2.12 and `@pierre/trees` 1.0.0-beta.5. Same-workload before/after/final-Pierre evidence is below | ## Bottom line -- There is **no fleet memory telemetry** — PostHog has zero memory/resource events; the TopBar ResourceConsumption widget samples `ps` live and keeps no history. The "over time" profile below is measured, not telemetered. -- The biggest memory growth over a session is **unbounded retention, not thread-shaped work**: parked xterm instances and per-org Electric collections. -- Two worker systems already exist and are each used for exactly one job: `WorkerTaskRunner` (main process, git status only) and the pierre diff pool (renderer, 8 workers, diffs only). Extend those before inventing anything. +- Fleet memory telemetry shipped in PR #5777 after the original sweep. It is disabled in development, so the "over time" profile and the rerun below remain direct measurements rather than PostHog data; production fleet percentiles will only exist after rollout and sample accumulation. +- The biggest memory growth over a session is **retention, not thread-shaped work**. Parked xterm/webview caps and prior-org Electric collection eviction have shipped; active-org table windowing remains open. +- Three worker systems now exist: desktop main's `WorkerTaskRunner` (v1 git status), the host-service generic pool (v2 git status + commit-file reads), and the pierre renderer pool (8 workers, diffs). Extend the matching system before inventing another request/response pool. +- The latest-main 8-workspace/20k-file test reproduced the reported renderer freeze. The profile points to eager changed-file-row mounting, not another worker-pool gap. The final shared-Pierre rerun had zero timeouts, 119.2 ms maximum CDP latency, 210.4 ms maximum event-loop delay, and 917.9 MiB peak renderer RSS versus 3 timeouts, 1,563.2 ms maximum CDP latency, 6,644 ms maximum delay, and 1,727.9 MiB peak RSS before the fix. -## Measured memory profile (dev app, CDP) +## Pre-fix measured memory profile (dev app, CDP) | State | JS heap | Renderer RSS | |---|---|---| @@ -32,7 +34,103 @@ | 5 terminals | 314 MB | — | | **18 terminals (17 parked)** | **558–570 MB** | **1.54–1.59 GB** | -Nothing is reclaimed on workspace switch: with 17/18 terminals parked, heap and all 18 WebGL contexts stayed put. +This was the pre-#5751 baseline: nothing was reclaimed on workspace switch, so with 17/18 terminals parked, heap and all 18 WebGL contexts stayed put. The controlled A/B below measures the shipped eviction behavior. + +## Latest-main 8-workspace / 20k-file churn rerun (2026-07-19) + +### Production-shaped visible lifecycle follow-up + +The lifecycle was rerun after merging the latest `origin/main`. The before-fix reproduction used upstream `637aa9ec79078c60c4fe8179a5f67a137740236b` at merge commit `e4bf24c7b5f5c3ec51471ddd1ceaef10f860ea07`. The first after-fix run used upstream `b06e97fc2bf6f179541e9529300d00351fd722fd` at merge commit `e9f37b219bba333b7e1f59e6b6fa1417715e070f`. The final shared-Pierre run merged upstream `20cde02967270178f60ac2c3fab2bbb955dfaad3` and tested commit `374ed2135c90a4961a3ae5331ebb122000b11965`. The current upstream state uses the local-first host-service database, so these runs used one local host-owned project with eight adopted synthetic workspaces instead of the now-stale cloud-project procedure. + +The reusable setup, measurement, evidence, and cleanup procedure is recorded in [`RENDERER_CHURN_UI_PROFILE_RUNBOOK.md`](./RENDERER_CHURN_UI_PROFILE_RUNBOOK.md). The [checkpoint video](./artifacts/renderer-churn-visible-lifecycle-checkpoints.mp4) shows the final shared-Pierre baseline, active-churn workspace lifecycle, and loaded cooldown Changes state. + +| Gate | Verified value | +|---|---| +| Worktree | `/Users/kietho/.superset/worktrees/1c99c8eb-1b31-4f04-9ac4-61a2760c74b6/agent/renderer-churn-current-main` | +| Branch / final tested commit | `agent/renderer-churn-current-main` / `374ed2135c90a4961a3ae5331ebb122000b11965` | +| Included `origin/main` | `20cde02967270178f60ac2c3fab2bbb955dfaad3` | +| API / renderer | `7501` / `7505` | +| Dedicated CDP | `127.0.0.1:9520`; final page `F07A1B359DD295ECA14F9C98B3B9428B`, Electron PID `28551`, renderer PID `29562` (plus the expected eight Pierre worker targets) | +| Auth / data boundary | Authenticated local-dev session; project/workspace rows only in this worktree's local host-service DB. Synthetic Git roots only; no production database, migration, repository, or credential literal | +| Visible lifecycle | Local/main plus `renderer-ui-1` through `renderer-ui-7`, v2 Workspaces list, and return to local/main, driven through real CDP pointer input on the dedicated renderer | + +Each workspace contained 20,000 tracked files and started with the same 600-file mix: 360 modified, 150 untracked, and 90 deleted. A separate mutator ran 120 ticks at 500 ms, appending to 200 existing tracked files in every workspace per tick: 1,600 writes/tick and 192,000 writes total. Measurements used a 10 s baseline, 60 s churn, and 10 s cooldown; CDP sampled about every 100 ms with a 2 s timeout, timer drift at 50 ms, and renderer RSS about every 500 ms. + +#### Reproduction and profile + +The latest-main before run **did reproduce the renderer freeze**. During churn there were three 2 s CDP timeouts, a 6,644 ms maximum timer delay, and visible workspace interactions taking up to 6.8 s. The renderer CPU profile covered another identical 192,000-write wave (69.5 s, 15,243 samples). Its application-frame hot path was `ChangesFoldersView` eagerly mapping every file to `FileRow`; `FileRow.tsx:51` and its per-row hooks/policy work were the leading inclusive application frames, surrounded by React element/DOM creation and garbage collection. + +That evidence supported one narrow requirement: bound changed-file DOM mounting. The first implementation used `@tanstack/react-virtual`; the final implementation removes that separate folder renderer and projects immediate-parent groups into the existing Pierre path model. Both view modes now share the Pierre status, icon, selection, context-menu, folding, and virtualization stack. Folder-mode callbacks translate projected paths back to real repository paths, covered by focused tests. + +The first live CDP check of the consolidation found a crucial integration issue: sizing the Pierre host to its complete content height made all 604 rows part of the viewport and disabled effective virtualization. The final narrow correction caps the host at 20 rows and leaves the complete scroll range inside Pierre. With 600 logical entries, CDP measured a 528 px client viewport, 14,472 px scroll range, and 43 mounted shadow rows. Tree mode also stayed at 43 mounted rows while exposing a 19,320 px range; a real wheel event moved it to `scrollTop=12000`, and switching back to folder mode produced projected `src › 0000/...` rows. A real pointer click on one of those rows exercised the mapped file-selection path. + +#### Before/after results + +| Renderer metric | Before baseline p50 / p95 / max | Before churn p50 / p95 / max | Before cooldown p50 / p95 / max | +|---|---:|---:|---:| +| Dedicated-CDP round trip | 0.85 / 1.14 / 16.32 ms | 2.72 / 43.80 / 1,563.21 ms | 7.08 / 49.27 / 124.97 ms | +| Renderer event-loop delay | 0.00 / 1.20 / 26.10 ms | 0.00 / 1.20 / 6,644.00 ms | 0.10 / 1.30 / 205.30 ms | +| Renderer RSS | 1,573.45 / 1,578.42 / 1,578.42 MiB | 1,561.66 / 1,709.66 / 1,727.89 MiB | 1,826.31 / 1,826.81 / 1,826.81 MiB | + +Before-fix CDP timeouts were 0 / **3** / 0 across baseline/churn/cooldown. + +| Renderer metric | After baseline p50 / p95 / max | After churn p50 / p95 / max | After cooldown p50 / p95 / max | +|---|---:|---:|---:| +| Dedicated-CDP round trip | 0.41 / 1.29 / 57.42 ms | 0.45 / 1.63 / 166.37 ms | 0.45 / 0.90 / 1.68 ms | +| Renderer event-loop delay | 0.00 / 1.20 / 82.50 ms | 0.00 / 1.10 / 220.10 ms | 0.00 / 1.10 / 1.60 ms | +| Renderer RSS | 645.95 / 667.91 / 667.91 MiB | 749.52 / 807.78 / 807.92 MiB | 770.58 / 772.53 / 772.53 MiB | + +After-fix CDP timeouts were **0 / 0 / 0**. The final latest-main mutator completed in 60.100 s versus 62.230 s before. All nine real-pointer transitions were accepted in 522–701 ms (551 ms p50 / 701 ms p95 / 701 ms max, including a fixed 500 ms observation window). The after-run used a fresh renderer launch, so the absolute RSS delta includes launch-state variation; the identical workload, bounded mounted-row count, timeout removal, and profile-to-fix match are the stronger causal evidence. + +#### Final shared-Pierre rerun + +The same 10 s / 60 s / 10 s workload was repeated after the Pierre consolidation and dependency upgrades. The separate mutator completed 192,000 tracked-file appends in 59.617 s. All eleven real-pointer actions (open Changes, seven workspace switches, Workspaces, return to local, reopen Changes) landed in 506–681 ms (556 ms p50 / 681 ms p95 / 681 ms max, including the fixed 500 ms observation window). + +| Renderer metric | Pierre baseline p50 / p95 / max | Pierre churn p50 / p95 / max | Pierre cooldown p50 / p95 / max | +|---|---:|---:|---:| +| Dedicated-CDP round trip | 0.35 / 0.40 / 0.45 ms | 0.44 / 4.90 / 119.20 ms | 0.44 / 1.90 / 2.28 ms | +| Renderer event-loop delay | 0.00 / 2.00 / 2.30 ms | 0.00 / 2.00 / 210.40 ms | 0.10 / 1.90 / 2.50 ms | +| Renderer RSS | 790.88 / 790.89 / 790.89 MiB | 856.00 / 917.66 / 917.94 MiB | 858.84 / 859.19 / 859.33 MiB | + +Shared-Pierre CDP timeouts were **0 / 0 / 0**. Absolute RSS again reflects fresh-launch variation; its churn increase was bounded and the interaction/timeout/DOM evidence agrees with the original hot-path diagnosis. + +The real-input during screenshot shows an active workspace immediately accepting the route change while its backend status populated. That status-completion wait is distinct from the reproduced renderer freeze: CDP round trips and timer sampling remained responsive. At the cooldown checkpoint, the cached local Changes count still showed 600 while direct synthetic-repository status was 800 (560 modified + 150 untracked + 90 deleted); the UI later refreshed to 800. The remaining risk is full-tree status freshness/completion latency on unwarmed workspaces. This renderer fix does not change git status scheduling or the cache-first behavior. The 20-row Pierre viewport also moves long-list scrolling into the tree surface; pointer, wheel, mode-switch, and file-selection paths passed, but multi-section nested-scroll ergonomics remain the main UI risk. + +### Earlier renderer-IPC diagnostic rerun + +### Target and safety gate + +The final fetch moved twice during the earlier diagnostic investigation, so those measurements were rerun after fast-forwarding to the then-current `origin/main`; the earlier `d383394b` and `ba78b917` samples are intentionally not used as its result. + +| Gate | Verified value | +|---|---| +| Worktree | `/Users/kietho/.superset/worktrees/1c99c8eb-1b31-4f04-9ac4-61a2760c74b6/agent/renderer-churn-current-main` | +| Branch | `agent/renderer-churn-current-main` | +| Commit | `cf68c53caf0ee0c5cac167d8189f7b4cf18c02a9` (equal to `origin/main` immediately before the final run) | +| API / renderer | `7501` / `7505` | +| Dedicated CDP | `127.0.0.1:9520`, page target `B7F0EF0681A3DEB0B26AAF6766F63636`, renderer PID `14230` | +| Visible route | `#/workspace/24e05c49-eee3-425c-a887-e5cf80527438`, rendered workspace-picker surface | +| Data boundary | `/private/tmp/superset-git-status-large-repo` plus eight `/private/tmp/superset-git-status-large-repo-worktrees/workspace-{0..7}` worktrees; no production database, migration, repository, or credential was used | + +Each worktree contained 20,000 tracked files and the harness's 600-file dirty mix (360 modified, 150 untracked, 90 deleted). The host event-bus run applied 400 paced tracked-file mutations per worktree at 5 ms intervals. The renderer run lasted 60.050 s and performed 120 churn ticks: every 500 ms it appended to 200 tracked files in every worktree (1,600 writes/tick, 192,000 writes total) while issuing all eight status requests every 2 s. A 20 s no-churn phase used the same status cadence as the baseline. + +The host result is the repository harness's exact event-bus path. The renderer status fan-out is synthetic diagnostic coverage through the real renderer → Electron IPC status path because the visible v2 sidebar did not surface the locally registered legacy worktree rows as selectable workspaces. The dev session also logged `host.ensure` membership failures for cached orgs, so its cloud-host lifecycle was not a valid substitute. The visible workspace-picker route and before/during screenshots were real end-to-end UI state, but the synthetic status results were not rendered and switching visibly among all eight workspaces was **not** exercised. Do not use this run to disprove a report whose necessary trigger is result rendering or that exact v2 workspace-switch lifecycle. + +### Results + +The freeze did **not** reproduce. There were zero 5 s CDP timeouts, zero 10 s status timeouts, no status errors, and the before/during screenshots showed the same responsive workspace-picker surface. The profiling gate therefore stayed closed: no CPU profile was captured and no product code was changed. + +| Renderer metric | 20 s baseline p50 / p95 / max | 60 s churn p50 / p95 / max | +|---|---:|---:| +| Dedicated-CDP round-trip latency | 0.4 / 2.0 / 27.8 ms | 0.4 / 2.0 / 59.8 ms | +| Per-worktree status latency | 486.3 / 1,863.6 / 1,870.0 ms | 461.1 / 562.8 / 1,061.2 ms | +| Eight-worktree status-batch latency | 519.2 / 1,870.2 / 1,870.2 ms | 506.7 / 581.2 / 1,061.3 ms | +| Renderer event-loop delay (50 ms timer drift) | 0.0 / 1.4 / 93.6 ms | 0.0 / 1.3 / 113.9 ms | +| Renderer RSS | 744.8 / 746.0 / 746.1 MiB | 763.2 / 771.1 / 771.1 MiB | + +The worker-mode host harness completed the measured portion in 5.321 s: 44 `git:changed` events coalesced to 25 refreshes, maximum active refreshes stayed at 4, and event-loop delay was 1.5 ms p50 / 11.4 ms p99 / 42.8 ms max. `/usr/bin/time -l` reported 509,984,768 bytes (486.4 MiB) maximum RSS for the harness process. The harness exposes p99 rather than p95 for its Node event-loop histogram. + +RSS did not show runaway growth in this run: churn maxed at 771.1 MiB and fell to 712.7 MiB after a 10 s cooldown plus explicit GC. JS heap was 125.0 MiB at churn end, 104.4 MiB before that GC, and 104.1 MiB after it; event listeners remained roughly flat (1,049 before GC, 1,061 after). Repeated forced invalidation and full eight-way status result transfer is intentionally harsher than the visible large-change UI's 10 s refetch floor. The production-shaped follow-up above now covers the missing result-render and workspace-switch lifecycle; its remaining caveat is background status freshness after full-tree churn. ### Controlled A/B — parked-terminal LRU eviction (SUPER-1545; measured at cap = 5, shipped default is 12 + configurable) @@ -64,12 +162,12 @@ Same A/B discipline, harder load: 24 terminal tabs, each running a real PTY proc Under this real-PTY streaming workload, eviction closes the WebSocket of released terminals, so their streams are neither delivered nor parsed — the before build parses all 24 streams into 23 live xterms forever. Heap is also flat under load in the eviction build vs climbing in the before build. A later 18-terminal synthetic-output rerun confirmed the memory result (−46% JS heap, −30% renderer RSS) but did not reproduce a CPU reduction (`TaskDuration` 1.41 s before vs 1.80 s after), so treat the 30% → 21% CPU sample as workload-specific rather than a general CPU claim. -## Verified findings — renderer +## Original findings and current disposition — renderer -- **Parked terminals are never disposed** (CDP). On workspace/tab switch the xterm wrapper reparents into a `position:fixed; left:-9999px` container under `
`; buffer (5,048 lines retained), addons, WebSocket, and **live WebGL context** all survive. `terminal-runtime-registry.ts`, `terminal-parking.ts`, detach-only unmount in `TerminalPane.tsx:162`. -- **No instance cap** (CDP): 18 terminals → 18 registry entries, 18 live WebGL contexts, zero evictions, zero GPU context losses (Electron/ANGLE tolerated 18; the classic ~16 cap did not trigger). +- **Original parked-terminal retention — resolved by PRs #5751, #5752, and #5771.** Before the fix, workspace/tab switch reparented xterm into a `position:fixed; left:-9999px` container under `` and retained its buffer, addons, WebSocket, and live WebGL context indefinitely. Current code LRU-evicts eligible parked runtimes, makes workspace-close disposal reliable, and skips eviction when state cannot persist safely. +- **Original absence of an instance cap — resolved by PRs #5751 and #5754.** The baseline reached 18 registry entries and 18 live WebGL contexts with zero evictions. Current defaults cap fully live parked xterms at 12 and hidden webviews at 3; the terminal cap is user-configurable from 2–64, while alternate-screen TUIs are exempt to avoid corrupt restoration. - **Scrollback 5000** (CDP: `term.options.scrollback === 5000`; buffer plateaued at 5,000 + viewport rows). `shared/constants.ts:42`. Park/persist serializes 1,000 lines to `localStorage` `terminal-buffer:` meanwhile, no layout shift since Streamdown already block-splits). No new infrastructure. -### 3. Electric collections: evict + window — the "grows while you do nothing" fix +### 3. Electric collections: eviction shipped; defer preload + window active-org tables -**Change:** (a) drop a prior org's collection set on org switch (add the missing `collectionsCache.delete` path + collection cleanup); (b) stop `preloadCollections` loading all 29 org tables at boot — preload only what the first screen needs, lazy-load the rest on surface open; (c) window the monotonically-growing tables (`githubPullRequests`, `tasks`, `automationRuns`, `chatSessions`) to a recent slice, paging older rows on demand. +**Shipped/change:** PR #5778 implemented (a): prior-org collection sets are dropped from `collectionsCache` on org switch and cleaned up. Remaining work is (b) stop `preloadCollections` loading all 29 active-org tables at boot — preload only what the first screen needs, lazy-load the rest on surface open; and (c) window the monotonically-growing tables (`githubPullRequests`, `tasks`, `automationRuns`, `chatSessions`) to a recent slice, paging older rows on demand. **Effect:** this is the only term in the profile that grows **with org age rather than user action** — the dev org already holds 3,501 PR rows + 1,719 tasks + 613 runs (~50 MB heap); a year-old active org will be several× that, paid by every client at every boot, forever. Windowing caps it permanently. Org-switch eviction returns the full per-org set (collections + secondary indexes) for multi-org users. Deferring preload also cuts boot-time sync burst and time-to-interactive. **Cost/risk:** windowed surfaces must keep the cache-first rendering rule (AGENTS.md §9) — never blank existing rows while older pages load. Sync writes that expect full-table presence need auditing before (c). Workers don't help here; retention policy does — the data must live on the thread that queries it. -### 4. Git compute into a host-service worker pool (v2-first — see companion plan) +### 4. Git compute into a host-service worker pool — shipped; continue measuring -**Change:** port the `WorkerTaskRunner` + protocol into host-service and move v2 git compute (`getGitStatusSnapshot`, numstat/untracked parsing, commit/diff reads) onto it. This is specced in `plans/20260717-host-service-git-worker-pool.md` — that plan is the source of truth for the pool design (static task registry, clone-safe boundary, idle reaping, inline fallback). Do **not** widen the desktop main-process copy of `WorkerTaskRunner`: in v2 the git compute lives in host-service, and the desktop pool dies with v1 (v1-sunset policy). +**Shipped:** PR #5750 added the host-service `WorkerTaskRunner` + protocol and moved v2 git status and commit-file reads into it; PR #5746 bounded watcher batches. `plans/20260717-host-service-git-worker-pool.md` remains the design and measurement record (static task registry, clone-safe boundary, idle reaping, inline fallback). Do **not** widen the desktop main-process copy of `WorkerTaskRunner`: in v2 the git compute lives in host-service, and the desktop pool dies with v1 (v1-sunset policy). **Effect:** every ms the host-service loop is busy is added latency for everything it serves — terminal relay, WS, all tRPC for the org — so git churn in big repos (branch storms, watcher-triggered refresh bursts) is felt as whole-app sluggishness, not as "git is slow". Offloading bounds loop occupancy so those stay flat. Port-scan/`ps` parsing measured trivial (per the companion plan's load test) — leave it. @@ -130,11 +228,11 @@ Under this real-PTY streaming workload, eviction closes the WebSocket of release **Cost/risk:** (a) is a signature change (`sessionHasRunningProcess` becomes async) with a small call-site ripple. (b) needs index handoff to the worker (transfer once, update incrementally) — the shapes are bounded (LRU 12 indexes, 500 results) so memory doesn't double meaningfully. Note: (b) cannot be a tenant of the request/response pool from the companion plan (its contract forbids worker-held state, and search needs the index resident worker-side) — it wants the plan's pinned-worker follow-up shape or a dedicated worker. -### 6. Memory telemetry — make the next pass data-driven +### 6. Memory telemetry — shipped; make the next pass data-driven -**Change:** sample `app.getAppMetrics()` + `process.memoryUsage()` in main every ~5 min; emit one PostHog `resource_snapshot` with per-process RSS and terminal/workspace/webview counts + uptime. +**Shipped:** PR #5777 samples `app.getAppMetrics()` + `process.memoryUsage()` in production every 5–6 min and emits one privacy-allowlisted PostHog `resource_snapshot` with process-class RSS/counts, main-process heap/RSS, uptime, and window/web-contents counts. It deliberately emits no IDs, names, paths, commands, or repository/terminal/user data. -**Effect:** today there is **zero** fleet memory data — this review had to be measured by hand on one machine. With snapshots: fleet percentiles of renderer RSS vs terminal count (validates rec 1's cap choice), regression detection when a release moves the curve, and before/after proof for each fix shipped from this list. Negligible cost (~12 events/user/hour, tiny payload). +**Effect:** after rollout and enough samples accumulate, the fleet gains renderer-RSS percentiles and release-regression detection instead of relying only on one-machine reviews. The current payload does not include terminal/workspace counts, so validating the parked-terminal cap against fleet usage still needs either privacy-safe bounded counts or a separate controlled cohort analysis. Cost remains negligible (~10–12 events/user/hour, tiny payload). ### Not worker candidates diff --git a/apps/desktop/docs/RENDERER_CHURN_UI_PROFILE_RUNBOOK.md b/apps/desktop/docs/RENDERER_CHURN_UI_PROFILE_RUNBOOK.md new file mode 100644 index 00000000000..8be8a373c71 --- /dev/null +++ b/apps/desktop/docs/RENDERER_CHURN_UI_PROFILE_RUNBOOK.md @@ -0,0 +1,86 @@ +# Renderer churn UI profile runbook + +Use this runbook to reproduce the production-shaped v2 workspace/Changes lifecycle with synthetic repositories. It complements `MEMORY_AND_WORKERS_REVIEW.md`; it is not a production-data load test. + +## Safety and identity gate + +Before starting, record all of the following in the result: + +1. Read the repository root and `apps/desktop/AGENTS.md` instructions. +2. Fetch `origin/main`, merge or rebase it into the test branch, and record both the tested commit and the included `origin/main` commit. +3. Read this worktree's final API and renderer ports from `.env`. Confirm the ports are owned by processes launched from this exact worktree. +4. Choose an unused dedicated CDP port. Resolve the Electron process from its command path, require `--remote-debugging-port=`, and require a page target whose URL uses this worktree's renderer port. +5. Verify `/api/auth/get-session` inside the matched renderer and require an active organization. Follow `apps/desktop/AGENTS.md` for local-only auth repair; never print a token or credential literal. +6. Require this worktree's local development database and host-service data root. Never use a production database, migration, production repository, copied production fixture, or another worktree's host DB. + +For another worktree, such as `/Users/kietho/.superset/worktrees/1c99c8eb-1b31-4f04-9ac4-61a2760c74b6/agent/workspace-switch-cache`, repeat the gate from that directory and choose its own unused CDP port. Do not reuse the renderer, API, PID, page target, or auth result from a different workspace. + +## Synthetic fixture + +Create one synthetic Git repository with 20,000 tracked files and seven external worktrees. Start every path with the same logical dirty mix: 360 modified, 150 untracked, and 90 deleted files. Keep the fixture under an explicit `/private/tmp` prefix. + +Use distinct branches for the external worktrees (`renderer-ui-1` through `renderer-ui-7`). Create the project through the visible **Add repository → Open from folder** journey, then adopt the existing external worktrees through the host-service `workspaceCreation.adopt` procedure. On current local-first main, the project and all eight workspace rows must live in this worktree's active-organization `host.db`. The visible Workspaces page and sidebar must both show all eight before measurement. + +The repository fixture used by the review was created with: + +```bash +bun packages/host-service/scripts/git-status-large-repo-profile.ts \ + --repo /private/tmp/superset-renderer-ui-large-repo \ + --out /tmp/superset-renderer-ui-setup-latest-main \ + --files 20000 --dirty 600 --events 1 --event-interval-ms 0 \ + --concurrency 4 --workspaces 8 --git-delay-ms 0 \ + --mode limited --flow event-bus --recreate +``` + +On the final tested main (`b06e97f`), that general-purpose harness threw after creating the fixture because its report path still expected `status.againstBase`. Treat that exception as a setup limitation, not a successful measurement: independently verify all roots, branches, and dirty counts before adopting them. Do not reuse the harness's partial result as renderer evidence. + +The mutator must target existing tracked paths. Validate one generated path with `git ls-files --error-unmatch` before starting. For the 20k fixture used on 2026-07-19, tracked paths were shaped like: + +```text +src/0000/file-000000.ts +... +src/0019/file-019999.ts +``` + +This validation is important: a wrong path silently creates untracked files and exercises the much more expensive untracked-file line counter instead of the requested tracked-file churn. + +## Measured workload + +Run three phases against a fresh launch. Keep the filesystem mutator and sampler as separate processes; attach the mutator's `close` listener immediately after spawning it so a fast post-fix run cannot finish before the listener exists. + +| Phase | Duration | Work | +|---|---:|---| +| Baseline | 10 s | No writes; collect CDP round-trip latency, 50 ms renderer timer drift, and renderer RSS | +| Churn | 60 s | 120 ticks at 500 ms; append to 200 existing tracked files in each of eight workspaces per tick (1,600 writes/tick, 192,000 total) | +| Cooldown | 10 s | Stop writes; continue the same measurements and observe Changes freshness | + +Run the filesystem mutator in a separate process so its synchronous filesystem work cannot starve the measurement process. Sample CDP about every 100 ms and RSS about every 500 ms. Report count, p50, p95, and max for every phase plus the number of 2 s CDP timeouts. Resolve renderer RSS from the process whose user-data directory and command path match this worktree; never select the first Electron renderer globally. + +During churn, use real visible pointer/keyboard input to: + +1. Open Changes in the initial main workspace. +2. Switch through `renderer-ui-1` through `renderer-ui-7`, opening Changes for each. +3. Open the v2 Workspaces list. +4. Return to main and visibly confirm the Changes list renders. + +Record the route transitions from the matched CDP page. Use CDP `Input.dispatchMouseEvent` for real pointer input, and use runtime evaluation only to observe the route, the Changes scroller, and numeric state. Do not assign DOM properties or call internal app APIs as end-to-end proof. Record click-to-observed-state wall times separately from CDP round-trip latency. Capture before/during/after screenshots and verify each screenshot agrees with its route and observed state. + +For a loaded Changes surface, record both the logical file count and the mounted DOM slice. When Pierre owns the viewport, record the `file-tree-container` shadow root's virtualized-list height, scroll viewport `clientHeight`/`scrollHeight`, and mounted `[data-type=item]` count. A virtualized result should retain the full scroll range while bounding mounted rows; a cached file count is not proof of status freshness. Exercise a real wheel event inside that viewport, switch both Folders and Tree modes with real pointer input, and select a projected folder-mode file row so path translation is covered end to end. + +## Evidence and decision gate + +- A responsive cached Changes list is not proof that background status is fresh. After cooldown, compare the visible count with a direct synthetic-repository status count and record any lag. +- Do not claim a freeze from a slow status result alone. Require the reported interaction to stop responding or exceed the defined timeout while the matched route and input journey are active. +- If the valid workload reproduces the freeze, capture a renderer CPU profile over the same mutation and visible-switch window and rank application frames by inclusive samples. Change product code only when the profile identifies a narrow hot path and the same workload can provide before/after evidence. In the 2026-07-19 run, eager `ChangesFoldersView` → `FileRow` mounting was that hot path; React/DOM creation and GC surrounded it. For a Pierre implementation, do not infer virtualization from the package alone: a host sized to full content makes all rows part of the viewport. Require a bounded host/client height, full scroll range, and bounded mounted-row count. +- If the valid workload does not reproduce, make documentation/measurement changes only. +- Discard any run whose paths, worktree count, auth, cloud rows, ports, page target, or mutation type do not match the gate. + +The 2026-07-19 checkpoint montage is [renderer-churn-visible-lifecycle-checkpoints.mp4](artifacts/renderer-churn-visible-lifecycle-checkpoints.mp4). It contains four-second baseline/during/cooldown checkpoints, in that order, from the final shared-Pierre run; it is a checkpoint montage, not a continuous recording of every click. Preserve the source screenshots until the video has been decoded with `ffprobe`/`ffmpeg` and visually checked. + +## Cleanup + +1. Delete the synthetic local project through the matched host-service `project.remove` procedure and verify the project/workspace rows are absent from the active-organization host DB. +2. Stop the dedicated desktop stack and restore any local host-service rows left by an interrupted cleanup saga. If the test started a supporting Electric container, return it to its prior stopped/running state. +3. Remove all eight synthetic Git worktrees with explicit `git worktree remove --force` targets, then remove only the validated synthetic `/private/tmp` roots. +4. Remove temporary auth, CDP, profiler, screenshot, and result files. Restore any supporting service (for example Electric) to its pre-test running/stopped state. +5. Confirm the worktree contains only the intended documentation/measurement changes and that the API, renderer, and dedicated CDP ports are no longer listening. diff --git a/apps/desktop/docs/artifacts/renderer-churn-visible-lifecycle-checkpoints.mp4 b/apps/desktop/docs/artifacts/renderer-churn-visible-lifecycle-checkpoints.mp4 new file mode 100644 index 00000000000..005f743b561 Binary files /dev/null and b/apps/desktop/docs/artifacts/renderer-churn-visible-lifecycle-checkpoints.mp4 differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fa501e22a28..8c65de01cfb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -79,8 +79,8 @@ "@mastra/core": "1.33.1", "@paper-design/shaders-react": "0.0.77", "@parcel/watcher": "2.5.6", - "@pierre/diffs": "1.2.2", - "@pierre/trees": "1.0.0-beta.3", + "@pierre/diffs": "1.2.12", + "@pierre/trees": "1.0.0-beta.5", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-label": "2.1.8", "@replit/codemirror-css-color-picker": "6.3.0", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx index 79f9e444f49..a3c74253e57 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -1,7 +1,6 @@ import { memo, useMemo } from "react"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import type { ChangesViewMode } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; -import { ChangesFoldersView } from "./components/ChangesFoldersView"; import { ChangesSection } from "./components/ChangesSection"; import { ChangesTreeView } from "./components/ChangesTreeView"; @@ -86,7 +85,10 @@ export const ChangesFileList = memo(function ChangesFileList({ } return ( - +{GROUP_ORDER.map((key) => { const groupFiles = grouped[key]; if (groupFiles.length === 0) return null; @@ -103,29 +105,19 @@ export const ChangesFileList = memo(function ChangesFileList({ : undefined } > - {viewMode === "tree" ? ( -- ) : ( - - )} + ); })} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx deleted file mode 100644 index 09c8e5d3634..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - type ChangesetFile, - getChangesetFileKey, -} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; -import type { FoldSignal } from "../../ChangesFileList"; -import { FileRow } from "../FileRow"; -import { FolderHeader } from "./components/FolderHeader"; - -const ROOT_FOLDER_KEY = ""; -const ROOT_FOLDER_LABEL = "Root Path"; - -interface ChangesFoldersViewProps { - files: ChangesetFile[]; - workspaceId: string; - worktreePath?: string; - /** Bumped by the toolbar's expand-all / collapse-all buttons. */ - foldSignal: FoldSignal; - onSelectFile?: ( - path: string, - openInNewTab?: boolean, - changeKey?: string, - ) => void; - onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; - onOpenInEditor?: (path: string) => void; -} - -interface FolderGroup { - folderPath: string; - files: ChangesetFile[]; -} - -/** - * Render a flat list of changed files grouped by their immediate parent - * folder (one level deep — v1's "grouped" mode, not the full tree). - * - * Differences from v1's `FileListGrouped`: - * - Collapse state tracked as a *closed* set, so folders that newly appear - * in the changeset default to open (v1 tracked an *expanded* set keyed by - * folder path, so a folder that didn't exist on first render stayed - * collapsed when it appeared later). - * - Per-folder bulk Stage/Unstage/Discard intentionally not ported — - * section-level bulk actions already cover the common case, and the - * per-folder buttons crowd the header. - */ -export const ChangesFoldersView = memo(function ChangesFoldersView({ - files, - workspaceId, - worktreePath, - foldSignal, - onSelectFile, - onOpenFile, - onOpenInEditor, -}: ChangesFoldersViewProps) { - const groups = useMemo(() => groupFilesByFolder(files), [files]); - const [closedFolders, setClosedFolders] = useState >(new Set()); - - const toggleFolder = useCallback((folderPath: string) => { - setClosedFolders((prev) => { - const next = new Set(prev); - if (next.has(folderPath)) next.delete(folderPath); - else next.add(folderPath); - return next; - }); - }, []); - - // React to expand-all / collapse-all from the toolbar — but only on a new - // signal, not when `groups` changes (which would re-apply the last action - // and stomp any folder the user re-toggled in between). - const lastFoldEpochRef = useRef(0); - useEffect(() => { - if (foldSignal.epoch === 0 || foldSignal.epoch === lastFoldEpochRef.current) - return; - lastFoldEpochRef.current = foldSignal.epoch; - setClosedFolders( - foldSignal.action === "collapse" - ? new Set(groups.map((g) => g.folderPath)) - : new Set(), - ); - }, [foldSignal, groups]); - - return ( - - {groups.map((group) => { - const isRoot = group.folderPath === ROOT_FOLDER_KEY; - const isOpen = !closedFolders.has(group.folderPath); - // `folderPath` ("" for the root group) is already the unique - // per-group discriminator — `groupFilesByFolder` keys a Map by it. - return ( -- ); -}); - -function groupFilesByFolder(files: ChangesetFile[]): FolderGroup[] { - const map = new Map-- ); - })} -toggleFolder(group.folderPath)} - /> - {isOpen && - group.files.map((file) => ( - - ))} - (); - for (const file of files) { - const lastSlash = file.path.lastIndexOf("/"); - const folderPath = - lastSlash >= 0 ? file.path.slice(0, lastSlash) : ROOT_FOLDER_KEY; - const group = map.get(folderPath); - if (group) group.push(file); - else map.set(folderPath, [file]); - } - return Array.from(map.entries()) - .map(([folderPath, groupFiles]) => ({ - folderPath, - files: groupFiles.sort((a, b) => - basenameOf(a.path).localeCompare(basenameOf(b.path)), - ), - })) - .sort((a, b) => { - // Root-level files come first so they read like the top of a tree. - if (a.folderPath === ROOT_FOLDER_KEY) - return b.folderPath === ROOT_FOLDER_KEY ? 0 : -1; - if (b.folderPath === ROOT_FOLDER_KEY) return 1; - return a.folderPath.localeCompare(b.folderPath); - }); -} - -function basenameOf(path: string): string { - const i = path.lastIndexOf("/"); - return i < 0 ? path : path.slice(i + 1); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx deleted file mode 100644 index a2adb025541..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; - -interface FolderHeaderProps { - /** Display label — a folder path like "src/components", or "Root Path". */ - label: string; - fileCount: number; - isOpen: boolean; - onToggle: () => void; -} - -/** - * Collapsible header for a folder group in the changes sidebar. Shows the - * folder path right-truncated (so the deepest segment stays visible) and the - * file count. The whole row toggles collapse — no chevron, matching v1's - * "grouped" variant. The full path is surfaced via the shadcn `Tooltip` (the - * native `title` attribute doesn't render reliably in our Electron renderer — - * `FileRow` uses the same component for its hover hint). - */ -export function FolderHeader({ - label, - fileCount, - isOpen, - onToggle, -}: FolderHeaderProps) { - return ( - - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/index.ts deleted file mode 100644 index 11b007479cb..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FolderHeader } from "./FolderHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/index.ts deleted file mode 100644 index 43c2d81ae42..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ChangesFoldersView } from "./ChangesFoldersView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index ace134660e0..9c94509438c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -32,6 +32,7 @@ import { type ChangesetFile, getChangesetFileKey, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import type { ChangesViewMode } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import { toAbsoluteWorkspacePath, toRelativeWorkspacePath, @@ -41,11 +42,19 @@ import { FileRowContextMenuItems } from "./components/FileRowContextMenuItems"; import { FolderContextMenuItems } from "./components/FolderContextMenuItems"; import { ShadowRowHoverActions } from "./components/ShadowRowHoverActions"; import { useMeasuredTreeHeight } from "./hooks/useMeasuredTreeHeight"; +import { + buildPierreProjection, + compareFolderProjectionEntries, +} from "./utils/buildPierreProjection"; import { buildTreeShape } from "./utils/buildTreeShape"; const ITEM_HEIGHT = 24; // Pierre rows carry `margin-block: 1px`, so each row occupies ITEM_HEIGHT + 2px. const ROW_BOX = ITEM_HEIGHT + 2; +// A full-content host disables Pierre's internal virtualizer by making every +// row part of its viewport. Cap the host while retaining Pierre's own complete +// scroll range; overscan still keeps the interaction boundary smooth. +const MAX_VIEWPORT_ROWS = 20; // Small cushion so the last row never clips against the host's `overflow: hidden`. const HEIGHT_CUSHION = 8; @@ -59,6 +68,7 @@ type SectionKind = ChangesetFile["source"]["kind"]; interface ChangesTreeViewProps { /** Files for a single section — caller has already pre-grouped by `source.kind`. */ files: ChangesetFile[]; + viewMode: ChangesViewMode; /** Section the files came from; used to scope context-menu/hover Discard. */ sectionKind: SectionKind; workspaceId: string; @@ -77,12 +87,13 @@ interface ChangesTreeViewProps { } /** - * Tree view of a single changes section, powered by `@pierre/trees`. Pierre - * builds the directory hierarchy from the flat path list and handles - * virtualization + status tints + icons; we layer on: + * Changes view of a single section, powered by `@pierre/trees` in both modes. + * Tree mode passes through real paths; folder mode projects every immediate + * parent into one top-level Pierre directory. Pierre handles virtualization, + * status tints, and icons; we layer on: * * - `renderRowDecoration`: `+N/−N` on files, file count on directories - * - `renderContextMenu`: file-row actions matching `FileRow`; folder-row + * - `renderContextMenu`: the existing file actions plus folder-row * actions (open in editor, copy path) * - hover actions overlay (Discard on unstaged + more-actions ⌄ dropdown) * - `useChangesSidebarFilePolicy` for settings-driven click routing @@ -94,6 +105,7 @@ interface ChangesTreeViewProps { */ export const ChangesTreeView = memo(function ChangesTreeView({ files, + viewMode, sectionKind, workspaceId, worktreePath, @@ -103,16 +115,29 @@ export const ChangesTreeView = memo(function ChangesTreeView({ onOpenFile, onOpenInEditor, }: ChangesTreeViewProps) { - const paths = useMemo(() => files.map((f) => f.path), [files]); + const projection = useMemo( + () => + buildPierreProjection( + files.map((file) => file.path), + viewMode, + ), + [files, viewMode], + ); + const { paths } = projection; const fileByPath = useMemo(() => { const map = new Map- - -{label} -(); - for (const file of files) map.set(file.path, file); + for (const file of files) { + const treePath = projection.treePathByFilePath.get(file.path); + if (treePath) map.set(treePath, file); + } return map; - }, [files]); + }, [files, projection]); const { dirs, dirFileCount } = useMemo(() => buildTreeShape(paths), [paths]); - const initialGitStatusEntriesRef = useRef(buildPierreGitStatus(files)); + const initialGitStatusEntriesRef = useRef( + buildPierreGitStatus(files, projection.treePathByFilePath), + ); // Callbacks routed through a ref so Pierre's stable handler closures // (resolved once at `useFileTree` time) always see the latest props. @@ -127,6 +152,7 @@ export const ChangesTreeView = memo(function ChangesTreeView({ const { model } = usePierreFileTree({ paths, + sort: viewMode === "folders" ? compareFolderProjectionEntries : "default", initialExpansion: "open", search: false, gitStatus: initialGitStatusEntriesRef.current, @@ -148,8 +174,10 @@ export const ChangesTreeView = memo(function ChangesTreeView({ }, [model, paths]); useEffect(() => { - model.setGitStatus(buildPierreGitStatus(files)); - }, [model, files]); + model.setGitStatus( + buildPierreGitStatus(files, projection.treePathByFilePath), + ); + }, [model, files, projection]); useFallthroughIcons(model); @@ -157,10 +185,14 @@ export const ChangesTreeView = memo(function ChangesTreeView({ // 100%`, which collapses to 0 inside this section's auto-height container); // fall back to a row-count estimate until that first measurement lands. const contentHeight = useMeasuredTreeHeight(model); - const treeHeight = + const fullTreeHeight = contentHeight != null ? contentHeight + HEIGHT_CUSHION : (dirs.length + paths.length) * ROW_BOX + HEIGHT_CUSHION; + const treeHeight = Math.min( + fullTreeHeight, + MAX_VIEWPORT_ROWS * ROW_BOX + HEIGHT_CUSHION, + ); const setAllDirsExpanded = useCallback( (expanded: boolean) => { @@ -194,23 +226,23 @@ export const ChangesTreeView = memo(function ChangesTreeView({ selectedFilePath && worktreePath ? toRelativeWorkspacePath(worktreePath, selectedFilePath) : selectedFilePath; + const selectedTreePath = selectedRelPath + ? projection.treePathByFilePath.get(selectedRelPath) + : undefined; useEffect(() => { - if (!selectedRelPath || !fileByPath.has(selectedRelPath)) return; + if (!selectedRelPath || !selectedTreePath) return; if (lastUserSelectRef.current === selectedRelPath) { lastUserSelectRef.current = null; return; } - model.focusPath(selectedRelPath); - }, [model, selectedRelPath, fileByPath]); + model.focusPath(selectedTreePath); + }, [model, selectedRelPath, selectedTreePath]); handlersRef.current.onSelect = (treePath) => { - lastUserSelectRef.current = treePath; const file = fileByPath.get(treePath); - onSelectFile?.( - treePath, - false, - file ? getChangesetFileKey(file) : undefined, - ); + if (!file) return; + lastUserSelectRef.current = file.path; + onSelectFile?.(file.path, false, getChangesetFileKey(file)); }; // Pierre's row decoration accepts text or icon, not arbitrary JSX. The // status indicator is already painted by `setGitStatus` (row tint + icon), @@ -231,20 +263,27 @@ export const ChangesTreeView = memo(function ChangesTreeView({ const { onClickCapture, findFileRow } = usePierreChangesSidebarRowClickPolicy( { getFileIntent: filePolicy.getIntent, - onSelectDiff: (rel, openInNewTab) => { - lastUserSelectRef.current = rel; - const file = fileByPath.get(rel); - onSelectFile?.( - rel, + onSelectDiff: (treePath, openInNewTab) => { + const file = fileByPath.get(treePath); + if (!file) return; + lastUserSelectRef.current = file.path; + onSelectFile?.(file.path, openInNewTab, getChangesetFileKey(file)); + }, + onOpenFile: (treePath, openInNewTab) => { + if (!worktreePath) return; + const file = fileByPath.get(treePath); + if (!file) return; + onOpenFile?.( + toAbsoluteWorkspacePath(worktreePath, file.path), openInNewTab, - file ? getChangesetFileKey(file) : undefined, ); }, - onOpenFile: (rel, openInNewTab) => { - if (!worktreePath) return; - onOpenFile?.(toAbsoluteWorkspacePath(worktreePath, rel), openInNewTab); + openInExternalEditor: (treePath) => { + const filePath = fileByPath.get(treePath)?.path; + const directoryPath = projection.directoryPathByTreePath.get(treePath); + const realPath = filePath ?? directoryPath; + if (realPath !== undefined) onOpenInEditor?.(realPath); }, - openInExternalEditor: (rel) => onOpenInEditor?.(rel), }, ); @@ -281,9 +320,12 @@ export const ChangesTreeView = memo(function ChangesTreeView({ ) => { const menuItems = (() => { if (item.kind === "directory") { + const treePath = stripTrailingSlash(item.path); + const directoryPath = projection.directoryPathByTreePath.get(treePath); + if (directoryPath === undefined) return null; return ( @@ -383,11 +425,16 @@ export const ChangesTreeView = memo(function ChangesTreeView({ ); }); -function buildPierreGitStatus(files: ChangesetFile[]): PierreGitStatusEntry[] { - return files.map((file) => ({ - path: file.path, - status: FILE_STATUS_TO_PIERRE[file.status], - })); +function buildPierreGitStatus( + files: ChangesetFile[], + treePathByFilePath: Map , +): PierreGitStatusEntry[] { + return files.flatMap((file) => { + const treePath = treePathByFilePath.get(file.path); + return treePath + ? [{ path: treePath, status: FILE_STATUS_TO_PIERRE[file.status] }] + : []; + }); } function formatDiffStats(additions: number, deletions: number): string { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts index 21cc151f396..4eeb7fad6df 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts @@ -6,10 +6,10 @@ import { useEffect, useState } from "react"; * an auto-height container — so the tree would be invisible. Pierre also writes * the true content height (rendered rows × itemHeight, *after* it flattens * single-child directory chains into one row) to the virtualized list's inline - * `style.height`; this hook mirrors that value so the caller can size the host - * explicitly. A naive `dirs + files` count would massively over-estimate - * because it doesn't know about flattening. Returns `null` until Pierre has - * rendered (caller should fall back to an estimate meanwhile). + * `style.height`; this hook mirrors that value so the caller can size or cap + * the host explicitly. A naive `dirs + files` count would massively + * over-estimate because it doesn't know about flattening. Returns `null` until + * Pierre has rendered (caller should fall back to an estimate meanwhile). */ export function useMeasuredTreeHeight(model: FileTree): number | null { const [height, setHeight] = useState (null); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.test.ts new file mode 100644 index 00000000000..cc9c86eddfe --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { + buildPierreProjection, + compareFolderProjectionEntries, +} from "./buildPierreProjection"; + +describe("buildPierreProjection", () => { + test("keeps real paths in tree mode", () => { + const projection = buildPierreProjection( + ["README.md", "src/components/Button.tsx"], + "tree", + ); + + expect(projection.paths).toEqual([ + "README.md", + "src/components/Button.tsx", + ]); + expect(projection.directoryPathByTreePath.get("src/components")).toBe( + "src/components", + ); + }); + + test("projects immediate parents into one Pierre directory", () => { + const projection = buildPierreProjection( + [ + "README.md", + "src/components/Button.tsx", + "src/components/Input.tsx", + "src/utils/format.ts", + ], + "folders", + ); + + expect(projection.paths).toEqual([ + "Root Path/README.md", + "src › components/Button.tsx", + "src › components/Input.tsx", + "src › utils/format.ts", + ]); + expect( + projection.filePathByTreePath.get("src › components/Button.tsx"), + ).toBe("src/components/Button.tsx"); + expect(projection.directoryPathByTreePath.get("src › components")).toBe( + "src/components", + ); + }); + + test("keeps root and same-named folders distinct", () => { + const projection = buildPierreProjection( + ["root.txt", "Root Path/nested.txt"], + "folders", + ); + + expect(projection.paths).toEqual([ + "Root Path/root.txt", + "Root Path (2)/nested.txt", + ]); + expect(projection.directoryPathByTreePath.get("Root Path")).toBe(""); + expect(projection.directoryPathByTreePath.get("Root Path (2)")).toBe( + "Root Path", + ); + }); +}); + +describe("compareFolderProjectionEntries", () => { + test("orders root first, then directories, then files descending", () => { + const directory = (basename: string) => ({ + basename, + depth: 0, + isDirectory: true, + path: `${basename}/`, + segments: [basename], + }); + const file = (basename: string) => ({ + basename, + depth: 1, + isDirectory: false, + path: `src/${basename}`, + segments: ["src", basename], + }); + + expect( + [directory("src"), directory("Root Path"), directory("apps")].sort( + compareFolderProjectionEntries, + ), + ).toEqual([directory("Root Path"), directory("apps"), directory("src")]); + expect( + [file("a.ts"), file("z.ts")].sort(compareFolderProjectionEntries), + ).toEqual([file("z.ts"), file("a.ts")]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.ts new file mode 100644 index 00000000000..81dfeac9409 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.ts @@ -0,0 +1,122 @@ +import type { FileTreeSortComparator } from "@pierre/trees"; +import type { ChangesViewMode } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; + +const ROOT_FOLDER_LABEL = "Root Path"; +const FOLDER_SEPARATOR = " › "; + +export interface PierreProjection { + paths: string[]; + treePathByFilePath: Map ; + filePathByTreePath: Map ; + directoryPathByTreePath: Map ; +} + +/** + * Pierre is path-first, so folder mode projects each immediate parent into one + * top-level directory. The maps keep every user action keyed to the real path. + */ +export function buildPierreProjection( + filePaths: string[], + viewMode: ChangesViewMode, +): PierreProjection { + if (viewMode === "tree") return buildTreeProjection(filePaths); + + const groupedPaths = new Map (); + for (const filePath of filePaths) { + const directoryPath = dirname(filePath); + const group = groupedPaths.get(directoryPath); + if (group) group.push(filePath); + else groupedPaths.set(directoryPath, [filePath]); + } + + const directoryPaths = [...groupedPaths.keys()].sort(compareDirectoryPaths); + const usedLabels = new Set (); + const paths: string[] = []; + const treePathByFilePath = new Map (); + const filePathByTreePath = new Map (); + const directoryPathByTreePath = new Map (); + + for (const directoryPath of directoryPaths) { + const baseLabel = + directoryPath === "" + ? ROOT_FOLDER_LABEL + : directoryPath.split("/").join(FOLDER_SEPARATOR); + const groupLabel = makeUniqueLabel(baseLabel, usedLabels); + directoryPathByTreePath.set(groupLabel, directoryPath); + for (const filePath of groupedPaths.get(directoryPath) ?? []) { + const treePath = `${groupLabel}/${basename(filePath)}`; + paths.push(treePath); + treePathByFilePath.set(filePath, treePath); + filePathByTreePath.set(treePath, filePath); + } + } + + return { + paths, + treePathByFilePath, + filePathByTreePath, + directoryPathByTreePath, + }; +} + +export const compareFolderProjectionEntries: FileTreeSortComparator = ( + left, + right, +) => { + if (left.isDirectory !== right.isDirectory) return left.isDirectory ? -1 : 1; + if (left.isDirectory) { + if (left.basename === ROOT_FOLDER_LABEL) return -1; + if (right.basename === ROOT_FOLDER_LABEL) return 1; + return left.basename.localeCompare(right.basename); + } + return right.basename.localeCompare(left.basename); +}; + +function buildTreeProjection(filePaths: string[]): PierreProjection { + const treePathByFilePath = new Map (); + const filePathByTreePath = new Map (); + const directoryPathByTreePath = new Map (); + for (const filePath of filePaths) { + treePathByFilePath.set(filePath, filePath); + filePathByTreePath.set(filePath, filePath); + const segments = filePath.split("/"); + for (let index = 1; index < segments.length; index++) { + const directoryPath = segments.slice(0, index).join("/"); + directoryPathByTreePath.set(directoryPath, directoryPath); + } + } + return { + paths: filePaths, + treePathByFilePath, + filePathByTreePath, + directoryPathByTreePath, + }; +} + +function makeUniqueLabel(baseLabel: string, usedLabels: Set ): string { + if (!usedLabels.has(baseLabel)) { + usedLabels.add(baseLabel); + return baseLabel; + } + let suffix = 2; + while (usedLabels.has(`${baseLabel} (${suffix})`)) suffix++; + const label = `${baseLabel} (${suffix})`; + usedLabels.add(label); + return label; +} + +function compareDirectoryPaths(left: string, right: string): number { + if (left === "") return right === "" ? 0 : -1; + if (right === "") return 1; + return left.localeCompare(right); +} + +function dirname(path: string): string { + const index = path.lastIndexOf("/"); + return index < 0 ? "" : path.slice(0, index); +} + +function basename(path: string): string { + const index = path.lastIndexOf("/"); + return index < 0 ? path : path.slice(index + 1); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/index.ts new file mode 100644 index 00000000000..367e6776960 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/index.ts @@ -0,0 +1,4 @@ +export { + buildPierreProjection, + compareFolderProjectionEntries, +} from "./buildPierreProjection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx deleted file mode 100644 index 6f745a4ef1c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuShortcut, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; -import { toast } from "@superset/ui/sonner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { workspaceTrpc } from "@superset/workspace-client"; -import { - ChevronDown, - ExternalLink, - FileText, - GitCompare, - SquarePlus, - Trash2, - Undo2, -} from "lucide-react"; -import { memo, useState } from "react"; -import { - modifierLabel, - useChangesSidebarFilePolicy, -} from "renderer/lib/clickPolicy"; -import { FileIcon } from "renderer/lib/fileIcons"; -import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; -import { StatusIndicator } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; -import { PathActionsMenuItems } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PathActionsMenuItems"; -import { - type ChangesetFile, - getChangesetFileKey, -} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; -import { toAbsoluteWorkspacePath } from "shared/absolute-paths"; - -function splitPath(path: string): { dir: string; basename: string } { - const lastSlash = path.lastIndexOf("/"); - if (lastSlash < 0) return { dir: "", basename: path }; - return { - dir: `${path.slice(0, lastSlash)}/`, - basename: path.slice(lastSlash + 1), - }; -} - -interface FileRowProps { - file: ChangesetFile; - workspaceId: string; - worktreePath?: string; - /** Hide the directory prefix — used when the row sits under a folder group. */ - hideDir?: boolean; - onSelect?: (path: string, openInNewTab?: boolean, changeKey?: string) => void; - onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; - onOpenInEditor?: (path: string) => void; -} - -export const FileRow = memo(function FileRow({ - file, - workspaceId, - worktreePath, - hideDir, - onSelect, - onOpenFile, - onOpenInEditor, -}: FileRowProps) { - const { dir: fullDir, basename } = splitPath(file.path); - const dir = hideDir ? "" : fullDir; - const oldBasename = - file.oldPath && (file.status === "renamed" || file.status === "copied") - ? splitPath(file.oldPath).basename - : null; - const absolutePath = worktreePath - ? toAbsoluteWorkspacePath(worktreePath, file.path) - : undefined; - const changeKey = getChangesetFileKey(file); - const canDiscard = file.source.kind === "unstaged"; - const [showDiscardConfirm, setShowDiscardConfirm] = useState(false); - const isDeleteAction = file.status === "untracked" || file.status === "added"; - const utils = workspaceTrpc.useUtils(); - const discardMutation = workspaceTrpc.git.discardChanges.useMutation({ - onSuccess: () => { - void utils.git.getStatus.invalidate({ workspaceId }); - void utils.git.getDiff.invalidate({ workspaceId }); - }, - onError: (err) => { - toast.error("Couldn't discard changes", { description: err.message }); - }, - }); - const confirmDiscard = () => { - setShowDiscardConfirm(false); - discardMutation.mutate({ workspaceId, filePath: file.path }); - }; - - const policy = useChangesSidebarFilePolicy(); - const diffNewTabTier = policy.tierForIntent("diffNewTab"); - const fileTier = policy.tierForIntent("file"); - const externalTier = policy.tierForIntent("external"); - - const rowButton = ( - - -- ); - - return ( -- {canDiscard && ( --- - )} -- - -Discard changes -- -- - -- -onSelect?.(file.path, false, changeKey)} - > - -- Open Diff - onSelect?.(file.path, true, changeKey)} - > - -- Open Diff in New Tab - {diffNewTabTier && ( - - {modifierLabel(diffNewTabTier)} - - )} -absolutePath && onOpenFile?.(absolutePath)} - disabled={!onOpenFile || !absolutePath} - > - -- Open File - {fileTier && ( - - {modifierLabel(fileTier)} - - )} -absolutePath && onOpenFile?.(absolutePath, true)} - disabled={!onOpenFile || !absolutePath} - > - -- Open File in New Tab - onOpenInEditor?.(file.path)} - disabled={!onOpenInEditor} - > - -- Open in Editor - {externalTier && ( - - {modifierLabel(externalTier)} - - )} -- - ); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/index.ts deleted file mode 100644 index 81ba5923974..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FileRow } from "./FileRow"; diff --git a/bun.lock b/bun.lock index 357c6bb1761..911f1372aa7 100644 --- a/bun.lock +++ b/bun.lock @@ -158,8 +158,8 @@ "@mastra/core": "1.33.1", "@paper-design/shaders-react": "0.0.77", "@parcel/watcher": "2.5.6", - "@pierre/diffs": "1.2.2", - "@pierre/trees": "1.0.0-beta.3", + "@pierre/diffs": "1.2.12", + "@pierre/trees": "1.0.0-beta.5", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-label": "2.1.8", "@replit/codemirror-css-color-picker": "6.3.0", @@ -2344,11 +2344,13 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], - "@pierre/diffs": ["@pierre/diffs@1.2.2", "", { "dependencies": { "@pierre/theme": "1.0.3", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-MvWLv2oSOJOF8oYXWLdhicguHM11G/VNWu6OPR5ZETolp2NM2/KPQG3cZTnKpJ6ImqEHwvw6Gl6z2gmmy2FQmQ=="], + "@pierre/diffs": ["@pierre/diffs@1.2.12", "", { "dependencies": { "@pierre/theme": "1.1.0", "@pierre/theming": "0.0.2", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-pY/gmgWL03WnagqCyCnBi3QtRXUv4hCIY6FYqd5b1ZGaoI6a4Bsji8j+yRl2RfzPh/8Hf19rCl1GE80G6a1cLQ=="], - "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], + "@pierre/theme": ["@pierre/theme@1.1.0", "", {}, "sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ=="], - "@pierre/trees": ["@pierre/trees@1.0.0-beta.3", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-gfV7V1AoceIwTSFwiiWl/89gNtJROyo2dFeYYuAkT4F3AbE+ajCIGZEICBw1ygmVxVetF9Kq1Xpjz8BhXXZwTQ=="], + "@pierre/theming": ["@pierre/theming@0.0.2", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw=="], + + "@pierre/trees": ["@pierre/trees@1.0.0-beta.5", "", { "dependencies": { "@pierre/theming": "0.0.2", "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-IzxkB9qv6GLbeEXObhlAD205LfYHiLeRwJdnaIdX0f5keTZF4X9EfiuEQ3QiyxOxouVVmUX3rX7m6a8zNMo/wA=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], @@ -7038,6 +7040,8 @@ "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@pierre/diffs/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "@pierre/trees/preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="], "@poppinss/colors/kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],- -- -{rowButton} -{policy.hint} -- -onSelect?.(file.path, false, changeKey)} - > - -- Open Diff - onSelect?.(file.path, true, changeKey)} - > - -- Open Diff in New Tab - {diffNewTabTier && ( - - {modifierLabel(diffNewTabTier)} - - )} -absolutePath && onOpenFile?.(absolutePath)} - disabled={!onOpenFile || !absolutePath} - > - -- Open File - {fileTier && ( - {modifierLabel(fileTier)} - )} -absolutePath && onOpenFile?.(absolutePath, true)} - disabled={!onOpenFile || !absolutePath} - > - -- Open File in New Tab - onOpenInEditor?.(file.path)} - disabled={!onOpenInEditor} - > - - {absolutePath && ( - <> -- Open in Editor - {externalTier && ( - - {modifierLabel(externalTier)} - - )} -- - > - )} - {canDiscard && ( - <> - - setShowDiscardConfirm(true)} - > - {isDeleteAction ? - > - )} -: } - {isDeleteAction ? "Delete" : "Discard changes"} - -