From 906354f19a5bf22d2267de745a32b9a35b710e64 Mon Sep 17 00:00:00 2001 From: elhoim Date: Tue, 21 Jul 2026 20:17:23 +0000 Subject: [PATCH 01/13] docs: add design spec for rclone queue widget Covers data source (log-tailing since --rc isn't enabled), widget behavior, caching strategy, and registration/testing plan. --- .../2026-07-21-rclone-queue-widget-design.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-21-rclone-queue-widget-design.md diff --git a/docs/superpowers/specs/2026-07-21-rclone-queue-widget-design.md b/docs/superpowers/specs/2026-07-21-rclone-queue-widget-design.md new file mode 100644 index 00000000..152817f3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-rclone-queue-widget-design.md @@ -0,0 +1,69 @@ +# RClone Queue Widget — Design + +## Problem + +`~/mounts/dropbox` (and similarly `~/mounts/gdrive`) are rclone VFS mounts writing to cloud storage asynchronously (`--dropbox-batch-mode async`). Writes land in the local VFS cache immediately but can queue for a while before actually uploading, especially under Dropbox rate-limiting (`too_many_requests` backoff). There is currently no at-a-glance way to see how large that pending-upload backlog is without manually grepping the rclone log or checking `systemctl status`. This design adds a ccstatusline widget that surfaces that queue length directly in the status line. + +## Data source + +The rclone systemd unit (`~/.config/systemd/user/rclone@.service`) runs with `--vfs-cache-poll-interval 1m` and `--log-file %h/.cache/rclone/%i.log`, which produces a periodic INFO-level line roughly once a minute: + +``` +2026/07/21 19:16:46 INFO : Dropbox root '': vfs cache: cleaned: objects 56315 (was 56315) in use 1381, to upload 1374, uploading 6, total size 45.479Gi (was 45.479Gi) +``` + +There is no `--rc` remote-control API enabled on this system, so this log line is the only non-invasive, no-subprocess data source available. The widget reads `~/.cache/rclone/.log`, and extracts the `to upload (\d+)` figure from the most recent matching line. + +This generalizes to any configured rclone remote by remote name (default `dropbox`; `gdrive` also works on this box since it uses the same `%i`-templated log path convention). + +## Widget behavior + +- **File:** `src/widgets/RCloneQueue.ts`, class `RCloneQueueWidget implements Widget`. +- **Category:** `'Environment'` (same as `FreeMemoryWidget`). +- **Display name:** `'RClone Queue'`. +- **Render format:** `RClone: ` normally; bare `` when `item.rawValue` is set (existing raw-value convention, see `FreeMemoryWidget`). +- **Configurable remote name:** stored in `item.metadata.remoteName`, default `'dropbox'`. Editable via an in-TUI text editor triggered by a custom keybind (same UX pattern as `CustomTextWidget`'s `(e)dit text` — an `(e)dit remote` keybind opens a text input pre-filled with the current remote name). +- **Log path derivation:** `path.join(os.homedir(), '.cache', 'rclone', `${remoteName}.log`)`. +- **Fallback / "n/a" cases** (per explicit user choice — show a visible placeholder rather than hiding the widget): + - Log file does not exist at the derived path. + - Log file exists but contains no `to upload (\d+)` match yet (e.g. mount just started, hasn't hit its first poll interval). + - In both cases: render `RClone: n/a` (or bare `n/a` in raw mode). +- **A genuine queue length of 0 is a real value**, rendered as `RClone: 0` — it is not treated as a fallback/empty case. + +## Performance: caching + +Status lines can render many times per second in an active terminal session (this codebase has prior perf work specifically about avoiding per-render subprocess/syscall storms — see the git-widget cache in `src/utils/git.ts`). Reading and regex-scanning a log file per render is cheap relative to spawning a process, but still unnecessary work when the underlying data only changes once a minute. + +- In-process cache: a `Map`. +- TTL: 15 seconds, fixed constant (not user-configurable — the underlying source only updates every ~60s, so 15s is already a safe margin against staleness while cutting re-parses by ~4x during rapid re-renders). +- No persistent cross-process cache is needed (unlike the git cache, which caches expensive git subprocess calls across processes) — a single log-tail read is cheap enough that in-process-only caching suffices. +- To avoid reading an ever-growing log file from the start every time, read only the last N bytes (e.g. last 64KB) via a seek-from-end read, then take the last regex match within that window. Rclone log files can grow large over weeks of uptime (observed >800k lines in production on this box), so a full-file read/scan must be avoided. + +## Registration + +Two-step registration, consistent with all existing widgets: + +1. Export `RCloneQueueWidget` from `src/widgets/index.ts`. +2. Add `{ type: 'rclone-queue', create: () => new widgets.RCloneQueueWidget() }` to the widget list in `src/utils/widget-manifest.ts`. + +No changes needed to a `WidgetType` union (the `type` field is a plain `z.string()`, not a strict enum) and no README/docs widget table exists to update (confirmed via search — no doc file lists widgets by name). + +## Testing + +`src/widgets/__tests__/RCloneQueue.test.ts`, following the existing widget test conventions (see `FreeMemory.test.ts`, `CacheWidgets.test.ts`): + +- Log-parsing regex against fixture log content: + - A normal line with `to upload N` → extracts `N`. + - Multiple matching lines → picks the value from the most recent (last) one. + - No matching line in the file → returns `null` (renders `n/a`). + - Malformed/truncated line (e.g. log rotated mid-write) → does not throw, returns `null`. +- Missing log file entirely → returns `null` (renders `n/a`). +- Cache behavior: two renders within the 15s TTL window result in only one file read (verified via a spy/mock on the file-read call), and a render after TTL expiry triggers a fresh read. +- `rawValue` rendering: bare number vs `RClone: ` prefix. +- Remote-name metadata editor: default value, and that editing updates `item.metadata.remoteName`. + +## Out of scope + +- No dynamic color thresholds based on queue size (matches existing widgets' static-color convention — user can recolor via the standard color picker). +- No support for the `--rc` HTTP API path (not enabled in this environment; log-tailing is the only source implemented). +- No display of "uploading" (active transfer count) or total cache size — queue length only, per explicit choice. From ce46cfc685b7425b21c3c176531ada6bdcba553b Mon Sep 17 00:00:00 2001 From: elhoim Date: Tue, 21 Jul 2026 20:17:46 +0000 Subject: [PATCH 02/13] docs: clarify log-tail read boundary handling in rclone widget spec Discard a partial first line in the 64KB read window instead of leaving the edge case unspecified. --- .../superpowers/specs/2026-07-21-rclone-queue-widget-design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-21-rclone-queue-widget-design.md b/docs/superpowers/specs/2026-07-21-rclone-queue-widget-design.md index 152817f3..c8a1a933 100644 --- a/docs/superpowers/specs/2026-07-21-rclone-queue-widget-design.md +++ b/docs/superpowers/specs/2026-07-21-rclone-queue-widget-design.md @@ -37,7 +37,8 @@ Status lines can render many times per second in an active terminal session (thi - In-process cache: a `Map`. - TTL: 15 seconds, fixed constant (not user-configurable — the underlying source only updates every ~60s, so 15s is already a safe margin against staleness while cutting re-parses by ~4x during rapid re-renders). - No persistent cross-process cache is needed (unlike the git cache, which caches expensive git subprocess calls across processes) — a single log-tail read is cheap enough that in-process-only caching suffices. -- To avoid reading an ever-growing log file from the start every time, read only the last N bytes (e.g. last 64KB) via a seek-from-end read, then take the last regex match within that window. Rclone log files can grow large over weeks of uptime (observed >800k lines in production on this box), so a full-file read/scan must be avoided. +- To avoid reading an ever-growing log file from the start every time, read only the last 64KB via a seek-from-end read (`fs.readSync` with an offset from the end, or read the file size first and slice), then split into lines and take the regex match from the last line that matches, scanning backwards. Rclone log files can grow large over weeks of uptime (observed >800k lines in production on this box), so a full-file read/scan must be avoided. +- Edge case: if the read window's first line is a partial line (cut off mid-write by the 64KB boundary), discard that first partial line before scanning — do not attempt to parse it. This only risks losing the single oldest line in the window, which is never the one being searched for (the newest matching line is always fully contained since 64KB comfortably covers many minutes of log output at this line rate). ## Registration From ff620b15c6a8d753ea0175b4ce2f1efd9c42fd70 Mon Sep 17 00:00:00 2001 From: elhoim Date: Tue, 21 Jul 2026 20:29:55 +0000 Subject: [PATCH 03/13] docs: add implementation plan for rclone queue widget 4 tasks: data layer (log tail + cache), widget class, remote-name editor, registration. TDD throughout, using bun test (not bunx vitest, which fails to load this repo's vitest.config.ts in this environment). --- .../plans/2026-07-21-rclone-queue-widget.md | 894 ++++++++++++++++++ 1 file changed, 894 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-21-rclone-queue-widget.md diff --git a/docs/superpowers/plans/2026-07-21-rclone-queue-widget.md b/docs/superpowers/plans/2026-07-21-rclone-queue-widget.md new file mode 100644 index 00000000..86efda9d --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-rclone-queue-widget.md @@ -0,0 +1,894 @@ +# RClone Queue Widget Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a ccstatusline widget that shows the pending-upload queue length for a configurable rclone VFS mount (default `dropbox`), by tailing that mount's own rclone log file — no subprocess spawned per render. + +**Architecture:** One new widget file (`src/widgets/RCloneQueue.tsx`) holds three layers in increasing order of dependency: (1) a pure data layer that derives the log path from a remote name, tails the last 64KB of the log file, and regex-extracts the most recent `to upload N` figure, wrapped in a 15s in-process cache; (2) the `RCloneQueueWidget` class implementing the standard `Widget` interface, rendering `RClone: ` or `RClone: n/a`; (3) an in-TUI text editor (`RCloneRemoteEditor`) for changing which remote name the widget watches, following the same `useInput`-driven pattern as `CustomTextWidget`'s editor. The widget is registered the same way every other widget is: one barrel export, one manifest entry. + +**Tech Stack:** TypeScript, React + Ink (TUI rendering), Zod (schema — no changes needed, `metadata` is already `Record`), Vitest test syntax executed via `bun test` (this repo's `vitest.config.ts` config fails to load under `bunx vitest run` in this environment — `execSync`/module spying only works under Bun's own test runner here; use `bun test ` for every test run in this plan, not `bunx vitest`). + +## Global Constraints + +- Full spec: `docs/superpowers/specs/2026-07-21-rclone-queue-widget-design.md`. +- Log path convention: `~/.cache/rclone/.log` (derived via `path.join(os.homedir(), '.cache', 'rclone', \`${remoteName}.log\`)`). +- Tail-read window: last 64KB of the log file (`TAIL_BYTES = 64 * 1024`), discarding a possibly-partial first line when the read didn't start at byte 0. +- Cache TTL: 15 seconds (`CACHE_TTL_MS = 15_000`), in-process only (no persistent cross-process cache needed). +- Render format: `RClone: ` normally, bare `` when `item.rawValue` is true; `RClone: n/a` (bare `n/a` in raw mode) when the log file is missing or has no matching line yet. A genuine queue length of `0` is a real value, not a fallback. +- Default remote name: `'dropbox'`, stored in `item.metadata.remoteName`, editable via an `(e)dit remote` in-TUI text editor. +- Widget category: `'Environment'`. Default color: `'blue'`. +- Every test in this plan runs via `bun test `, not `bunx vitest run ` (confirmed during design research: `bunx vitest run` fails to even load `vitest.config.ts` in this environment, while `bun test` runs the exact same Vitest-syntax test files successfully). +- Run `bun install` once at the start of Task 1 if `node_modules` isn't already present in this worktree. + +--- + +### Task 1: Core data layer — log path, tail read, parse, cache + +**Files:** +- Create: `src/widgets/RCloneQueue.tsx` +- Create: `src/widgets/__tests__/RCloneQueue.test.tsx` + +**Interfaces:** +- Produces (used by Task 2): `getRcloneLogPath(remoteName: string): string`, `getQueueLength(remoteName: string, now?: number): number | null`, `clearRCloneQueueCache(): void`, `CACHE_TTL_MS: number` (exported constant), `DEFAULT_REMOTE_NAME: string` (exported constant, value `'dropbox'`). +- Also produced (used only by this task's own tests, but exported for direct unit testing): `readLogTail(logPath: string, maxBytes?: number): string | null`, `parseQueueLength(logText: string): number | null`. + +- [ ] **Step 1: Confirm dependencies are installed** + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && ls node_modules/.bin/vitest 2>/dev/null || bun install` +Expected: either the file already exists, or `bun install` completes with a package count printed (e.g. `547 packages installed`). + +- [ ] **Step 2: Write the failing tests for `parseQueueLength`, `readLogTail`, and `getQueueLength`** + +Create `src/widgets/__tests__/RCloneQueue.test.tsx`: + +```tsx +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { + clearRCloneQueueCache, + DEFAULT_REMOTE_NAME, + getQueueLength, + getRcloneLogPath, + parseQueueLength, + readLogTail +} from '../RCloneQueue'; + +describe('parseQueueLength', () => { + it('extracts the queue length from a normal vfs cache stats line', () => { + const log = `2026/07/21 19:16:46 INFO : Dropbox root '': vfs cache: cleaned: objects 56315 (was 56315) in use 1381, to upload 1374, uploading 6, total size 45.479Gi (was 45.479Gi)\n`; + expect(parseQueueLength(log)).toBe(1374); + }); + + it('picks the most recent matching line when there are several', () => { + const log = [ + 'INFO : vfs cache: cleaned: in use 100, to upload 90, uploading 2, total size 1Gi', + 'INFO : some unrelated line', + 'INFO : vfs cache: cleaned: in use 50, to upload 40, uploading 1, total size 1Gi' + ].join('\n'); + expect(parseQueueLength(log)).toBe(40); + }); + + it('returns null when no line matches', () => { + const log = 'INFO : Dropbox root \'\': Copied (new)\nINFO : some other unrelated line\n'; + expect(parseQueueLength(log)).toBeNull(); + }); + + it('returns null for an empty string', () => { + expect(parseQueueLength('')).toBeNull(); + }); + + it('does not throw on a malformed/truncated line', () => { + const log = 'garbage that mentions to upload but not a number: to upload abc\n'; + expect(parseQueueLength(log)).toBeNull(); + }); +}); + +describe('readLogTail', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rclone-queue-widget-test-')); + + afterEach(() => { + for (const file of fs.readdirSync(tmpDir)) { + fs.unlinkSync(path.join(tmpDir, file)); + } + }); + + it('returns null when the file does not exist', () => { + expect(readLogTail(path.join(tmpDir, 'does-not-exist.log'))).toBeNull(); + }); + + it('returns the full content when the file is smaller than maxBytes', () => { + const logPath = path.join(tmpDir, 'small.log'); + fs.writeFileSync(logPath, 'line one\nline two\n'); + expect(readLogTail(logPath, 1024)).toBe('line one\nline two\n'); + }); + + it('discards a partial first line when the read window starts mid-file', () => { + const logPath = path.join(tmpDir, 'large.log'); + // "AAAAA\n" (6 bytes) + "to upload 42\n" (13 bytes) = 19 bytes total. + // With maxBytes=13, the read window starts at byte 6, landing exactly + // on the second line's start (no partial line to discard in this case), + // so use an offset that actually lands mid-line instead: + fs.writeFileSync(logPath, 'AAAAA\nto upload 42\n'); + const content = fs.readFileSync(logPath, 'utf8'); + expect(content.length).toBe(19); + // maxBytes=15 makes the window start at byte 4, which is inside "AAAAA" + const tail = readLogTail(logPath, 15); + expect(tail).not.toBeNull(); + expect(tail).not.toContain('AAA'); + expect(parseQueueLength(tail ?? '')).toBe(42); + }); +}); + +describe('getQueueLength (cache)', () => { + const remoteName = 'test-remote'; + const logPath = getRcloneLogPath(remoteName); + + beforeEach(() => { + clearRCloneQueueCache(); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + }); + + afterEach(() => { + clearRCloneQueueCache(); + if (fs.existsSync(logPath)) { + fs.rmSync(logPath); + } + }); + + it('returns the default remote name constant', () => { + expect(DEFAULT_REMOTE_NAME).toBe('dropbox'); + }); + + it('reads a fresh value when nothing is cached yet', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 7, uploading 1, total size 1Gi\n'); + expect(getQueueLength(remoteName, 1000)).toBe(7); + }); + + it('returns the cached value within the TTL window even if the file changes', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 7, uploading 1, total size 1Gi\n'); + expect(getQueueLength(remoteName, 1000)).toBe(7); + + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 999, uploading 1, total size 1Gi\n'); + // Still within CACHE_TTL_MS (15000) of the first call. + expect(getQueueLength(remoteName, 1000 + CACHE_TTL_MS - 1)).toBe(7); + }); + + it('re-reads the file once the TTL window has elapsed', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 7, uploading 1, total size 1Gi\n'); + expect(getQueueLength(remoteName, 1000)).toBe(7); + + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 999, uploading 1, total size 1Gi\n'); + expect(getQueueLength(remoteName, 1000 + CACHE_TTL_MS)).toBe(999); + }); + + it('returns null (and caches null) when the log file does not exist', () => { + expect(getQueueLength(remoteName, 1000)).toBeNull(); + }); + + it('derives the log path from ~/.cache/rclone/.log', () => { + expect(getRcloneLogPath('gdrive')).toBe(path.join(os.homedir(), '.cache', 'rclone', 'gdrive.log')); + }); +}); +``` + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun test src/widgets/__tests__/RCloneQueue.test.tsx` +Expected: FAIL — `Cannot find module '../RCloneQueue'` (the file doesn't exist yet). + +- [ ] **Step 3: Implement the pure data layer** + +Create `src/widgets/RCloneQueue.tsx` with this content (widget class and editor come in later tasks — for now just the data layer, so it compiles standalone): + +```tsx +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +export const DEFAULT_REMOTE_NAME = 'dropbox'; +export const CACHE_TTL_MS = 15_000; +const TAIL_BYTES = 64 * 1024; +const TO_UPLOAD_RE = /to upload (\d+)/; + +interface QueueCacheEntry { + value: number | null; + createdAt: number; +} + +const queueCache = new Map(); + +export function getRcloneLogPath(remoteName: string): string { + return path.join(os.homedir(), '.cache', 'rclone', `${remoteName}.log`); +} + +export function readLogTail(logPath: string, maxBytes: number = TAIL_BYTES): string | null { + let fd: number; + try { + fd = fs.openSync(logPath, 'r'); + } catch { + return null; + } + + try { + const size = fs.fstatSync(fd).size; + const readSize = Math.min(size, maxBytes); + const start = size - readSize; + const buffer = Buffer.alloc(readSize); + if (readSize > 0) { + fs.readSync(fd, buffer, 0, readSize, start); + } + + let text = buffer.toString('utf8'); + if (start > 0) { + // The window may start mid-line; discard that partial first line. + const firstNewline = text.indexOf('\n'); + text = firstNewline === -1 ? '' : text.slice(firstNewline + 1); + } + + return text; + } finally { + fs.closeSync(fd); + } +} + +export function parseQueueLength(logText: string): number | null { + const lines = logText.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line) { + continue; + } + + const match = TO_UPLOAD_RE.exec(line); + const raw = match?.[1]; + if (raw !== undefined) { + return parseInt(raw, 10); + } + } + + return null; +} + +export function getQueueLength(remoteName: string, now: number = Date.now()): number | null { + const cached = queueCache.get(remoteName); + if (cached && now - cached.createdAt < CACHE_TTL_MS) { + return cached.value; + } + + const logPath = getRcloneLogPath(remoteName); + const text = readLogTail(logPath); + const value = text === null ? null : parseQueueLength(text); + queueCache.set(remoteName, { value, createdAt: now }); + return value; +} + +export function clearRCloneQueueCache(): void { + queueCache.clear(); +} +``` + +- [ ] **Step 4: Run tests again to verify they pass** + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun test src/widgets/__tests__/RCloneQueue.test.tsx` +Expected: PASS — all tests green (14 tests: 5 `parseQueueLength` + 3 `readLogTail` + 6 `getQueueLength`). + +- [ ] **Step 5: Lint and commit** + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun tsc --noEmit && bunx eslint src/widgets/RCloneQueue.tsx src/widgets/__tests__/RCloneQueue.test.tsx --config eslint.config.js --max-warnings=0` +Expected: no output, exit code 0 (clean). + +```bash +cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter +git add src/widgets/RCloneQueue.tsx src/widgets/__tests__/RCloneQueue.test.tsx +git commit -m "feat(widgets): add rclone queue data layer (log tail + cache)" +``` + +--- + +### Task 2: `RCloneQueueWidget` class + +**Files:** +- Modify: `src/widgets/RCloneQueue.tsx` (append the widget class; do not touch Task 1's data-layer functions) +- Modify: `src/widgets/__tests__/RCloneQueue.test.tsx` (append widget tests) + +**Interfaces:** +- Consumes (from Task 1): `getQueueLength(remoteName: string): number | null`, `DEFAULT_REMOTE_NAME: string`. +- Produces (used by Task 3): `getRemoteName(item: WidgetItem): string` (exported helper), the `RCloneQueueWidget` class itself. + +- [ ] **Step 1: Write the failing widget tests** + +Append to `src/widgets/__tests__/RCloneQueue.test.tsx` (add these imports to the existing top-of-file import block, and add this `describe` block at the end of the file): + +Add to imports: +```tsx +import type { + RenderContext, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +``` + +Append: +```tsx +describe('RCloneQueueWidget', () => { + const remoteName = 'test-remote-widget'; + const logPath = getRcloneLogPath(remoteName); + + beforeEach(() => { + clearRCloneQueueCache(); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + }); + + afterEach(() => { + clearRCloneQueueCache(); + if (fs.existsSync(logPath)) { + fs.rmSync(logPath); + } + }); + + describe('metadata', () => { + const widget = new RCloneQueueWidget(); + + it('returns correct display name', () => { + expect(widget.getDisplayName()).toBe('RClone Queue'); + }); + + it('returns correct category', () => { + expect(widget.getCategory()).toBe('Environment'); + }); + + it('returns blue as default color', () => { + expect(widget.getDefaultColor()).toBe('blue'); + }); + + it('supports raw value', () => { + expect(widget.supportsRawValue()).toBe(true); + }); + + it('supports colors', () => { + const item: WidgetItem = { id: 'rc', type: 'rclone-queue' }; + expect(widget.supportsColors(item)).toBe(true); + }); + + it('shows the default remote name in the editor display when unset', () => { + const item: WidgetItem = { id: 'rc', type: 'rclone-queue' }; + expect(widget.getEditorDisplay(item).displayText).toBe('RClone Queue (dropbox)'); + }); + + it('shows the configured remote name in the editor display', () => { + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName: 'gdrive' } }; + expect(widget.getEditorDisplay(item).displayText).toBe('RClone Queue (gdrive)'); + }); + }); + + describe('preview mode', () => { + const widget = new RCloneQueueWidget(); + + it('returns labeled mock data', () => { + const context: RenderContext = { isPreview: true }; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue' }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('RClone: 385'); + }); + + it('returns bare mock data when rawValue is set', () => { + const context: RenderContext = { isPreview: true }; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', rawValue: true }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('385'); + }); + }); + + describe('render', () => { + const widget = new RCloneQueueWidget(); + + it('renders the queue length from the configured remote\'s log', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 12, uploading 1, total size 1Gi\n'); + const context: RenderContext = {}; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName } }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('RClone: 12'); + }); + + it('renders the bare number when rawValue is set', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 12, uploading 1, total size 1Gi\n'); + const context: RenderContext = {}; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName }, rawValue: true }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('12'); + }); + + it('renders "n/a" when the log file does not exist', () => { + const context: RenderContext = {}; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName } }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('RClone: n/a'); + }); + + it('renders bare "n/a" when the log file does not exist and rawValue is set', () => { + const context: RenderContext = {}; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName }, rawValue: true }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('n/a'); + }); + + it('renders 0 as a real value, not as n/a', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 0, uploading 0, total size 1Gi\n'); + const context: RenderContext = {}; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName } }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('RClone: 0'); + }); + + it('defaults to the dropbox remote when no metadata is set', () => { + expect(getRemoteName({ id: 'rc', type: 'rclone-queue' })).toBe('dropbox'); + }); + + it('uses the configured remote name from metadata', () => { + expect(getRemoteName({ id: 'rc', type: 'rclone-queue', metadata: { remoteName: 'gdrive' } })).toBe('gdrive'); + }); + }); +}); +``` + +Also update the `RCloneQueue` import line at the top of the test file to pull in the new names: + +```tsx +import { + clearRCloneQueueCache, + DEFAULT_REMOTE_NAME, + getQueueLength, + getRcloneLogPath, + getRemoteName, + parseQueueLength, + RCloneQueueWidget, + readLogTail +} from '../RCloneQueue'; +``` + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun test src/widgets/__tests__/RCloneQueue.test.tsx` +Expected: FAIL — `getRemoteName` and `RCloneQueueWidget` are not exported yet. + +- [ ] **Step 2: Implement `getRemoteName` and `RCloneQueueWidget`** + +Append to `src/widgets/RCloneQueue.tsx` (after the Task 1 functions, before nothing else exists yet — this is the end of the file for now). First add these imports at the top of the file, alongside the existing `fs`/`os`/`path` imports: + +```tsx +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +``` + +Then append this to the bottom of the file: + +```tsx +export function getRemoteName(item: WidgetItem): string { + return item.metadata?.remoteName ?? DEFAULT_REMOTE_NAME; +} + +export class RCloneQueueWidget implements Widget { + getDefaultColor(): string { return 'blue'; } + getDescription(): string { return 'Shows the pending upload queue length for an rclone VFS mount (e.g. Dropbox)'; } + getDisplayName(): string { return 'RClone Queue'; } + getCategory(): string { return 'Environment'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: `${this.getDisplayName()} (${getRemoteName(item)})` }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + return item.rawValue ? '385' : 'RClone: 385'; + } + + const remoteName = getRemoteName(item); + const queueLength = getQueueLength(remoteName); + + if (queueLength === null) { + return item.rawValue ? 'n/a' : 'RClone: n/a'; + } + + return item.rawValue ? `${queueLength}` : `RClone: ${queueLength}`; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} +``` + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun test src/widgets/__tests__/RCloneQueue.test.tsx` +Expected: PASS — all tests green (14 from Task 1 + 16 new = 30 tests). + +- [ ] **Step 3: Lint and commit** + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun tsc --noEmit && bunx eslint src/widgets/RCloneQueue.tsx src/widgets/__tests__/RCloneQueue.test.tsx --config eslint.config.js --max-warnings=0` +Expected: no output, exit code 0. + +```bash +cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter +git add src/widgets/RCloneQueue.tsx src/widgets/__tests__/RCloneQueue.test.tsx +git commit -m "feat(widgets): add RCloneQueueWidget render/preview/metadata" +``` + +--- + +### Task 3: Remote-name in-TUI editor + +**Files:** +- Modify: `src/widgets/RCloneQueue.tsx` (append editor component + wire it into the widget class) +- Modify: `src/widgets/__tests__/RCloneQueue.test.tsx` (append editor interaction tests, using the same mock-TTY-stream harness as `src/widgets/shared/__tests__/symbol-override-editor.test.tsx`) + +**Interfaces:** +- Consumes (from Tasks 1-2): `DEFAULT_REMOTE_NAME`, `getRemoteName(item)`, the `RCloneQueueWidget` class (its `getCustomKeybinds` and `renderEditor` methods are added in this task, not new methods elsewhere). +- Produces: nothing consumed by later tasks — this is the last widget-internals task before registration. + +- [ ] **Step 1: Write the failing editor tests** + +Add these imports to the top of `src/widgets/__tests__/RCloneQueue.test.tsx` (alongside the existing ones): + +```tsx +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import stripAnsi from 'strip-ansi'; +``` + +Append this harness and `describe` block at the end of `src/widgets/__tests__/RCloneQueue.test.tsx`: + +```tsx +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 120; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string } + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + return Object.assign(stream as unknown as NodeJS.WriteStream, { + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +function renderRemoteEditor(item: WidgetItem, onComplete = vi.fn(), onCancel = vi.fn()) { + const widget = new RCloneQueueWidget(); + const editorElement = widget.renderEditor?.({ widget: item, onComplete, onCancel, action: 'edit-remote' }); + if (!editorElement) { + throw new Error('renderEditor did not return an element'); + } + + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render(editorElement, { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + }); + + return { + instance, stdin, stdout, stderr, onComplete, onCancel + }; +} + +function cleanupEditor(rendered: ReturnType): void { + rendered.instance.unmount(); + rendered.instance.cleanup(); + rendered.stdin.destroy(); + rendered.stdout.destroy(); + rendered.stderr.destroy(); +} + +function getPlainOutput(output: string): string { + return stripAnsi(output).replace(/\r\n/g, '\n'); +} + +describe('RCloneQueueWidget custom keybind', () => { + it('exposes an (e)dit remote keybind', () => { + const widget = new RCloneQueueWidget(); + expect(widget.getCustomKeybinds?.()).toEqual([ + { key: 'e', label: '(e)dit remote', action: 'edit-remote' } + ]); + }); +}); + +describe('RCloneRemoteEditor', () => { + it('shows the current remote name pre-filled', async () => { + const rendered = renderRemoteEditor({ id: 'rc', type: 'rclone-queue', metadata: { remoteName: 'gdrive' } }); + try { + await flushInk(); + const output = getPlainOutput(rendered.stdout.getOutput()); + expect(output).toContain('gdrive'); + } finally { + cleanupEditor(rendered); + } + }); + + it('saves the typed remote name on Enter', async () => { + const rendered = renderRemoteEditor({ id: 'rc', type: 'rclone-queue' }); + try { + await flushInk(); + // Backspace out "dropbox" (7 chars), then type "gdrive". + rendered.stdin.write('\b'.repeat(7)); + await flushInk(); + rendered.stdin.write('gdrive'); + await flushInk(); + rendered.stdin.write('\r'); + await flushInk(); + + const updated = rendered.onComplete.mock.calls[0]?.[0] as WidgetItem | undefined; + expect(updated?.metadata?.remoteName).toBe('gdrive'); + } finally { + cleanupEditor(rendered); + } + }); + + it('falls back to the default remote name when saved empty', async () => { + const rendered = renderRemoteEditor({ id: 'rc', type: 'rclone-queue' }); + try { + await flushInk(); + rendered.stdin.write('\b'.repeat(7)); + await flushInk(); + rendered.stdin.write('\r'); + await flushInk(); + + const updated = rendered.onComplete.mock.calls[0]?.[0] as WidgetItem | undefined; + expect(updated?.metadata?.remoteName).toBe('dropbox'); + } finally { + cleanupEditor(rendered); + } + }); + + it('cancels without calling onComplete on Escape', async () => { + const rendered = renderRemoteEditor({ id: 'rc', type: 'rclone-queue' }); + try { + await flushInk(); + rendered.stdin.write('\x1b'); + await flushInk(); + + expect(rendered.onComplete).not.toHaveBeenCalled(); + expect(rendered.onCancel).toHaveBeenCalledTimes(1); + } finally { + cleanupEditor(rendered); + } + }); +}); +``` + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun test src/widgets/__tests__/RCloneQueue.test.tsx` +Expected: FAIL — `widget.renderEditor` and `widget.getCustomKeybinds` are `undefined` (not implemented yet), so `renderRemoteEditor` throws. + +- [ ] **Step 2: Implement the editor component and wire it into the widget** + +Add these imports at the top of `src/widgets/RCloneQueue.tsx`, alongside the existing ones: + +```tsx +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import { shouldInsertInput } from '../utils/input-guards'; +``` + +Also extend the `Widget`-types import to include `CustomKeybind` and `WidgetEditorProps`: + +```tsx +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetEditorProps, + WidgetItem +} from '../types/Widget'; +``` + +Add the edit-action constant near the other exported constants: + +```tsx +export const EDIT_REMOTE_ACTION = 'edit-remote'; +``` + +Add these two methods to the `RCloneQueueWidget` class (after `getEditorDisplay`, before `render`): + +```tsx + getCustomKeybinds(): CustomKeybind[] { + return [{ key: 'e', label: '(e)dit remote', action: EDIT_REMOTE_ACTION }]; + } +``` + +And after the `supportsColors` method, still inside the class: + +```tsx + renderEditor(props: WidgetEditorProps): React.ReactElement { + return ; + } +``` + +Finally, append the editor component at the end of the file: + +```tsx +const RCloneRemoteEditor: React.FC = ({ widget, onComplete, onCancel }) => { + const [text, setText] = useState(getRemoteName(widget)); + const [cursorPos, setCursorPos] = useState(text.length); + + useInput((input, key) => { + if (key.return) { + const trimmed = text.trim(); + onComplete({ + ...widget, + metadata: { ...widget.metadata, remoteName: trimmed.length > 0 ? trimmed : DEFAULT_REMOTE_NAME } + }); + } else if (key.escape) { + onCancel(); + } else if (key.leftArrow) { + setCursorPos(pos => Math.max(0, pos - 1)); + } else if (key.rightArrow) { + setCursorPos(pos => Math.min(text.length, pos + 1)); + } else if (key.backspace) { + setCursorPos((pos) => { + if (pos > 0) { + setText(t => t.slice(0, pos - 1) + t.slice(pos)); + return pos - 1; + } + return pos; + }); + } else if (key.delete) { + setText((t) => { + if (cursorPos < t.length) { + return t.slice(0, cursorPos) + t.slice(cursorPos + 1); + } + return t; + }); + } else if (shouldInsertInput(input, key)) { + setText(t => t.slice(0, cursorPos) + input + t.slice(cursorPos)); + setCursorPos(pos => pos + input.length); + } + }); + + let display = 'Enter rclone remote name: '; + for (let i = 0; i < text.length; i++) { + display += i === cursorPos ? `\x1b[7m${text[i]}\x1b[0m` : text[i]; + } + if (cursorPos >= text.length) { + display += '\x1b[7m \x1b[0m'; + } + + return ( + + {display} + {'←→ move cursor, Enter save, ESC cancel (default: dropbox)'} + + ); +}; +``` + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun test src/widgets/__tests__/RCloneQueue.test.tsx` +Expected: PASS — all tests green (30 from Tasks 1-2 + 5 new = 35 tests). + +- [ ] **Step 3: Lint and commit** + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun tsc --noEmit && bunx eslint src/widgets/RCloneQueue.tsx src/widgets/__tests__/RCloneQueue.test.tsx --config eslint.config.js --max-warnings=0` +Expected: no output, exit code 0. If ESLint flags the `\x1b` escape codes or hex-escape style, match the exact style already used in `src/widgets/CustomText.tsx` for its cursor-highlight sequences (it uses the same `\x1b[7m...\x1b[0m` pattern and passes lint in this repo already). + +```bash +cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter +git add src/widgets/RCloneQueue.tsx src/widgets/__tests__/RCloneQueue.test.tsx +git commit -m "feat(widgets): add remote-name editor to RCloneQueueWidget" +``` + +--- + +### Task 4: Registration and final verification + +**Files:** +- Modify: `src/widgets/index.ts:75` (end of file — append one export line) +- Modify: `src/utils/widget-manifest.ts:93` (end of `WIDGET_MANIFEST` array — append one entry) + +**Interfaces:** +- Consumes: `RCloneQueueWidget` from `./RCloneQueue` (Task 2). +- Produces: nothing further — this is the last task. + +- [ ] **Step 1: Add the barrel export** + +In `src/widgets/index.ts`, the file currently ends with: +```ts +export { RemoteControlStatusWidget } from './RemoteControlStatus'; +``` + +Append a new line after it: +```ts +export { RCloneQueueWidget } from './RCloneQueue'; +``` + +- [ ] **Step 2: Add the manifest entry** + +In `src/utils/widget-manifest.ts`, the `WIDGET_MANIFEST` array currently ends with: +```ts + { type: 'compaction-counter', create: () => new widgets.CompactionCounterWidget() } +]; +``` + +Change it to: +```ts + { type: 'compaction-counter', create: () => new widgets.CompactionCounterWidget() }, + { type: 'rclone-queue', create: () => new widgets.RCloneQueueWidget() } +]; +``` + +- [ ] **Step 3: Write a manifest registration test** + +Check whether a manifest-level test already exists: + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && find src -iname "*widget-manifest*" -path "*__tests__*"` + +If a file like `src/utils/__tests__/widget-manifest.test.ts` exists, open it and add a case following its existing pattern asserting `'rclone-queue'` creates an `RCloneQueueWidget` instance (mirror however the existing test asserts this for e.g. `'free-memory'` or `'compaction-counter'`). If no such test file exists, skip this step — manifest wiring is still covered end-to-end by Step 4 below. + +- [ ] **Step 4: Run the full test suite, typecheck, and lint** + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun test 2>&1 | tail -30` +Expected: all test files pass, including `src/widgets/__tests__/RCloneQueue.test.tsx` (35 tests) and no regressions in any other widget test file. + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun run lint` +Expected: exits 0 with no output (this runs both `tsc --noEmit` and the full-repo `eslint --max-warnings=0`, so it also catches any stray issue across all touched files). + +- [ ] **Step 5: Manual smoke check (build + run once)** + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && bun run build` +Expected: build completes without errors, `dist/ccstatusline.js` is produced. + +Run: `cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter && cat scripts/payload.example.json | bun start` +Expected: the tool runs and prints a status line without throwing (the new widget won't appear unless added to the example config, so this step just confirms nothing crashes on import/registration — the `rclone-queue` type is now a valid, loadable widget type). + +- [ ] **Step 6: Commit** + +```bash +cd /home/elhoim/ccstatusline/.claude/worktrees/rclone-dropbox-queue-counter +git add src/widgets/index.ts src/utils/widget-manifest.ts +git commit -m "feat(widgets): register rclone-queue widget" +``` + +If Step 3 above added a manifest test file change, include it in this commit instead of a separate one. + +--- + +## Post-plan note (not a task — informational) + +Once this lands, to actually see the widget in your own status line you'll add an `rclone-queue` item to your ccstatusline config via the normal in-TUI widget picker (category **Environment** → **RClone Queue**), then press `e` on it to set the remote name if it's not `dropbox`. No code changes are needed for that step — it's just using the feature. From 53b728dea3b9ec14a65f83a7ed91c12fe827b076 Mon Sep 17 00:00:00 2001 From: elhoim Date: Tue, 21 Jul 2026 20:41:16 +0000 Subject: [PATCH 04/13] feat(widgets): add rclone queue data layer (log tail + cache) --- src/widgets/RCloneQueue.tsx | 84 +++++++++++++ src/widgets/__tests__/RCloneQueue.test.tsx | 137 +++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 src/widgets/RCloneQueue.tsx create mode 100644 src/widgets/__tests__/RCloneQueue.test.tsx diff --git a/src/widgets/RCloneQueue.tsx b/src/widgets/RCloneQueue.tsx new file mode 100644 index 00000000..985ff225 --- /dev/null +++ b/src/widgets/RCloneQueue.tsx @@ -0,0 +1,84 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +export const DEFAULT_REMOTE_NAME = 'dropbox'; +export const CACHE_TTL_MS = 15_000; +const TAIL_BYTES = 64 * 1024; +const TO_UPLOAD_RE = /to upload (\d+)/; + +interface QueueCacheEntry { + value: number | null; + createdAt: number; +} + +const queueCache = new Map(); + +export function getRcloneLogPath(remoteName: string): string { + return path.join(os.homedir(), '.cache', 'rclone', `${remoteName}.log`); +} + +export function readLogTail(logPath: string, maxBytes: number = TAIL_BYTES): string | null { + let fd: number; + try { + fd = fs.openSync(logPath, 'r'); + } catch { + return null; + } + + try { + const size = fs.fstatSync(fd).size; + const readSize = Math.min(size, maxBytes); + const start = size - readSize; + const buffer = Buffer.alloc(readSize); + if (readSize > 0) { + fs.readSync(fd, buffer, 0, readSize, start); + } + + let text = buffer.toString('utf8'); + if (start > 0) { + // The window may start mid-line; discard that partial first line. + const firstNewline = text.indexOf('\n'); + text = firstNewline === -1 ? '' : text.slice(firstNewline + 1); + } + + return text; + } finally { + fs.closeSync(fd); + } +} + +export function parseQueueLength(logText: string): number | null { + const lines = logText.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line) { + continue; + } + + const match = TO_UPLOAD_RE.exec(line); + const raw = match?.[1]; + if (raw !== undefined) { + return parseInt(raw, 10); + } + } + + return null; +} + +export function getQueueLength(remoteName: string, now: number = Date.now()): number | null { + const cached = queueCache.get(remoteName); + if (cached && now - cached.createdAt < CACHE_TTL_MS) { + return cached.value; + } + + const logPath = getRcloneLogPath(remoteName); + const text = readLogTail(logPath); + const value = text === null ? null : parseQueueLength(text); + queueCache.set(remoteName, { value, createdAt: now }); + return value; +} + +export function clearRCloneQueueCache(): void { + queueCache.clear(); +} diff --git a/src/widgets/__tests__/RCloneQueue.test.tsx b/src/widgets/__tests__/RCloneQueue.test.tsx new file mode 100644 index 00000000..969d6ab1 --- /dev/null +++ b/src/widgets/__tests__/RCloneQueue.test.tsx @@ -0,0 +1,137 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { + CACHE_TTL_MS, + DEFAULT_REMOTE_NAME, + clearRCloneQueueCache, + getQueueLength, + getRcloneLogPath, + parseQueueLength, + readLogTail +} from '../RCloneQueue'; + +describe('parseQueueLength', () => { + it('extracts the queue length from a normal vfs cache stats line', () => { + const log = `2026/07/21 19:16:46 INFO : Dropbox root '': vfs cache: cleaned: objects 56315 (was 56315) in use 1381, to upload 1374, uploading 6, total size 45.479Gi (was 45.479Gi)\n`; + expect(parseQueueLength(log)).toBe(1374); + }); + + it('picks the most recent matching line when there are several', () => { + const log = [ + 'INFO : vfs cache: cleaned: in use 100, to upload 90, uploading 2, total size 1Gi', + 'INFO : some unrelated line', + 'INFO : vfs cache: cleaned: in use 50, to upload 40, uploading 1, total size 1Gi' + ].join('\n'); + expect(parseQueueLength(log)).toBe(40); + }); + + it('returns null when no line matches', () => { + const log = 'INFO : Dropbox root \'\': Copied (new)\nINFO : some other unrelated line\n'; + expect(parseQueueLength(log)).toBeNull(); + }); + + it('returns null for an empty string', () => { + expect(parseQueueLength('')).toBeNull(); + }); + + it('does not throw on a malformed/truncated line', () => { + const log = 'garbage that mentions to upload but not a number: to upload abc\n'; + expect(parseQueueLength(log)).toBeNull(); + }); +}); + +describe('readLogTail', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rclone-queue-widget-test-')); + + afterEach(() => { + for (const file of fs.readdirSync(tmpDir)) { + fs.unlinkSync(path.join(tmpDir, file)); + } + }); + + it('returns null when the file does not exist', () => { + expect(readLogTail(path.join(tmpDir, 'does-not-exist.log'))).toBeNull(); + }); + + it('returns the full content when the file is smaller than maxBytes', () => { + const logPath = path.join(tmpDir, 'small.log'); + fs.writeFileSync(logPath, 'line one\nline two\n'); + expect(readLogTail(logPath, 1024)).toBe('line one\nline two\n'); + }); + + it('discards a partial first line when the read window starts mid-file', () => { + const logPath = path.join(tmpDir, 'large.log'); + // "AAAAA\n" (6 bytes) + "to upload 42\n" (13 bytes) = 19 bytes total. + // With maxBytes=13, the read window starts at byte 6, landing exactly + // on the second line's start (no partial line to discard in this case), + // so use an offset that actually lands mid-line instead: + fs.writeFileSync(logPath, 'AAAAA\nto upload 42\n'); + const content = fs.readFileSync(logPath, 'utf8'); + expect(content.length).toBe(19); + // maxBytes=15 makes the window start at byte 4, which is inside "AAAAA" + const tail = readLogTail(logPath, 15); + expect(tail).not.toBeNull(); + expect(tail).not.toContain('AAA'); + expect(parseQueueLength(tail ?? '')).toBe(42); + }); +}); + +describe('getQueueLength (cache)', () => { + const remoteName = 'test-remote'; + const logPath = getRcloneLogPath(remoteName); + + beforeEach(() => { + clearRCloneQueueCache(); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + }); + + afterEach(() => { + clearRCloneQueueCache(); + if (fs.existsSync(logPath)) { + fs.rmSync(logPath); + } + }); + + it('returns the default remote name constant', () => { + expect(DEFAULT_REMOTE_NAME).toBe('dropbox'); + }); + + it('reads a fresh value when nothing is cached yet', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 7, uploading 1, total size 1Gi\n'); + expect(getQueueLength(remoteName, 1000)).toBe(7); + }); + + it('returns the cached value within the TTL window even if the file changes', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 7, uploading 1, total size 1Gi\n'); + expect(getQueueLength(remoteName, 1000)).toBe(7); + + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 999, uploading 1, total size 1Gi\n'); + // Still within CACHE_TTL_MS (15000) of the first call. + expect(getQueueLength(remoteName, 1000 + CACHE_TTL_MS - 1)).toBe(7); + }); + + it('re-reads the file once the TTL window has elapsed', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 7, uploading 1, total size 1Gi\n'); + expect(getQueueLength(remoteName, 1000)).toBe(7); + + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 999, uploading 1, total size 1Gi\n'); + expect(getQueueLength(remoteName, 1000 + CACHE_TTL_MS)).toBe(999); + }); + + it('returns null (and caches null) when the log file does not exist', () => { + expect(getQueueLength(remoteName, 1000)).toBeNull(); + }); + + it('derives the log path from ~/.cache/rclone/.log', () => { + expect(getRcloneLogPath('gdrive')).toBe(path.join(os.homedir(), '.cache', 'rclone', 'gdrive.log')); + }); +}); From 0890d8c3be59d674cb914285a71dcaeb213163d4 Mon Sep 17 00:00:00 2001 From: elhoim Date: Tue, 21 Jul 2026 20:49:58 +0000 Subject: [PATCH 05/13] feat(widgets): add RCloneQueueWidget render/preview/metadata --- src/widgets/RCloneQueue.tsx | 41 +++++++ src/widgets/__tests__/RCloneQueue.test.tsx | 120 +++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/src/widgets/RCloneQueue.tsx b/src/widgets/RCloneQueue.tsx index 985ff225..eac884f8 100644 --- a/src/widgets/RCloneQueue.tsx +++ b/src/widgets/RCloneQueue.tsx @@ -2,6 +2,14 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + export const DEFAULT_REMOTE_NAME = 'dropbox'; export const CACHE_TTL_MS = 15_000; const TAIL_BYTES = 64 * 1024; @@ -82,3 +90,36 @@ export function getQueueLength(remoteName: string, now: number = Date.now()): nu export function clearRCloneQueueCache(): void { queueCache.clear(); } + +export function getRemoteName(item: WidgetItem): string { + return item.metadata?.remoteName ?? DEFAULT_REMOTE_NAME; +} + +export class RCloneQueueWidget implements Widget { + getDefaultColor(): string { return 'blue'; } + getDescription(): string { return 'Shows the pending upload queue length for an rclone VFS mount (e.g. Dropbox)'; } + getDisplayName(): string { return 'RClone Queue'; } + getCategory(): string { return 'Environment'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: `${this.getDisplayName()} (${getRemoteName(item)})` }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + return item.rawValue ? '385' : 'RClone: 385'; + } + + const remoteName = getRemoteName(item); + const queueLength = getQueueLength(remoteName); + + if (queueLength === null) { + return item.rawValue ? 'n/a' : 'RClone: n/a'; + } + + return item.rawValue ? `${queueLength}` : `RClone: ${queueLength}`; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/__tests__/RCloneQueue.test.tsx b/src/widgets/__tests__/RCloneQueue.test.tsx index 969d6ab1..4385a7d5 100644 --- a/src/widgets/__tests__/RCloneQueue.test.tsx +++ b/src/widgets/__tests__/RCloneQueue.test.tsx @@ -9,12 +9,19 @@ import { it } from 'vitest'; +import type { + RenderContext, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; import { CACHE_TTL_MS, DEFAULT_REMOTE_NAME, + RCloneQueueWidget, clearRCloneQueueCache, getQueueLength, getRcloneLogPath, + getRemoteName, parseQueueLength, readLogTail } from '../RCloneQueue'; @@ -135,3 +142,116 @@ describe('getQueueLength (cache)', () => { expect(getRcloneLogPath('gdrive')).toBe(path.join(os.homedir(), '.cache', 'rclone', 'gdrive.log')); }); }); + +describe('RCloneQueueWidget', () => { + const remoteName = 'test-remote-widget'; + const logPath = getRcloneLogPath(remoteName); + + beforeEach(() => { + clearRCloneQueueCache(); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + }); + + afterEach(() => { + clearRCloneQueueCache(); + if (fs.existsSync(logPath)) { + fs.rmSync(logPath); + } + }); + + describe('metadata', () => { + const widget = new RCloneQueueWidget(); + + it('returns correct display name', () => { + expect(widget.getDisplayName()).toBe('RClone Queue'); + }); + + it('returns correct category', () => { + expect(widget.getCategory()).toBe('Environment'); + }); + + it('returns blue as default color', () => { + expect(widget.getDefaultColor()).toBe('blue'); + }); + + it('supports raw value', () => { + expect(widget.supportsRawValue()).toBe(true); + }); + + it('supports colors', () => { + const item: WidgetItem = { id: 'rc', type: 'rclone-queue' }; + expect(widget.supportsColors(item)).toBe(true); + }); + + it('shows the default remote name in the editor display when unset', () => { + const item: WidgetItem = { id: 'rc', type: 'rclone-queue' }; + expect(widget.getEditorDisplay(item).displayText).toBe('RClone Queue (dropbox)'); + }); + + it('shows the configured remote name in the editor display', () => { + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName: 'gdrive' } }; + expect(widget.getEditorDisplay(item).displayText).toBe('RClone Queue (gdrive)'); + }); + }); + + describe('preview mode', () => { + const widget = new RCloneQueueWidget(); + + it('returns labeled mock data', () => { + const context: RenderContext = { isPreview: true }; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue' }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('RClone: 385'); + }); + + it('returns bare mock data when rawValue is set', () => { + const context: RenderContext = { isPreview: true }; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', rawValue: true }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('385'); + }); + }); + + describe('render', () => { + const widget = new RCloneQueueWidget(); + + it('renders the queue length from the configured remote\'s log', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 12, uploading 1, total size 1Gi\n'); + const context: RenderContext = {}; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName } }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('RClone: 12'); + }); + + it('renders the bare number when rawValue is set', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 12, uploading 1, total size 1Gi\n'); + const context: RenderContext = {}; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName }, rawValue: true }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('12'); + }); + + it('renders "n/a" when the log file does not exist', () => { + const context: RenderContext = {}; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName } }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('RClone: n/a'); + }); + + it('renders bare "n/a" when the log file does not exist and rawValue is set', () => { + const context: RenderContext = {}; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName }, rawValue: true }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('n/a'); + }); + + it('renders 0 as a real value, not as n/a', () => { + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 0, uploading 0, total size 1Gi\n'); + const context: RenderContext = {}; + const item: WidgetItem = { id: 'rc', type: 'rclone-queue', metadata: { remoteName } }; + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('RClone: 0'); + }); + + it('defaults to the dropbox remote when no metadata is set', () => { + expect(getRemoteName({ id: 'rc', type: 'rclone-queue' })).toBe('dropbox'); + }); + + it('uses the configured remote name from metadata', () => { + expect(getRemoteName({ id: 'rc', type: 'rclone-queue', metadata: { remoteName: 'gdrive' } })).toBe('gdrive'); + }); + }); +}); From 01e4b5c5d7c88f6d40d5f988192c9f418d047896 Mon Sep 17 00:00:00 2001 From: elhoim Date: Tue, 21 Jul 2026 21:06:09 +0000 Subject: [PATCH 06/13] feat(widgets): add remote-name editor to RCloneQueueWidget --- src/widgets/RCloneQueue.tsx | 75 ++++++++++ src/widgets/__tests__/RCloneQueue.test.tsx | 151 ++++++++++++++++++++- 2 files changed, 225 insertions(+), 1 deletion(-) diff --git a/src/widgets/RCloneQueue.tsx b/src/widgets/RCloneQueue.tsx index eac884f8..3a47c299 100644 --- a/src/widgets/RCloneQueue.tsx +++ b/src/widgets/RCloneQueue.tsx @@ -1,17 +1,27 @@ import fs from 'fs'; +import { + Box, + Text, + useInput +} from 'ink'; import os from 'os'; import path from 'path'; +import React, { useState } from 'react'; import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; +import { shouldInsertInput } from '../utils/input-guards'; export const DEFAULT_REMOTE_NAME = 'dropbox'; export const CACHE_TTL_MS = 15_000; +export const EDIT_REMOTE_ACTION = 'edit-remote'; const TAIL_BYTES = 64 * 1024; const TO_UPLOAD_RE = /to upload (\d+)/; @@ -105,6 +115,10 @@ export class RCloneQueueWidget implements Widget { return { displayText: `${this.getDisplayName()} (${getRemoteName(item)})` }; } + getCustomKeybinds(): CustomKeybind[] { + return [{ key: 'e', label: '(e)dit remote', action: EDIT_REMOTE_ACTION }]; + } + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { return item.rawValue ? '385' : 'RClone: 385'; @@ -122,4 +136,65 @@ export class RCloneQueueWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + + renderEditor(props: WidgetEditorProps): React.ReactElement { + return ; + } } + +const RCloneRemoteEditor: React.FC = ({ widget, onComplete, onCancel }) => { + const [text, setText] = useState(getRemoteName(widget)); + const [cursorPos, setCursorPos] = useState(text.length); + + useInput((input, key) => { + if (key.return) { + const trimmed = text.trim(); + onComplete({ + ...widget, + metadata: { ...(widget.metadata ?? {}), remoteName: trimmed.length > 0 ? trimmed : DEFAULT_REMOTE_NAME } + }); + } else if (key.escape) { + onCancel(); + } else if (key.leftArrow) { + setCursorPos(pos => Math.max(0, pos - 1)); + } else if (key.rightArrow) { + setCursorPos(pos => Math.min(text.length, pos + 1)); + } else if (key.backspace) { + setCursorPos((pos) => { + if (pos > 0) { + setText(t => t.slice(0, pos - 1) + t.slice(pos)); + return pos - 1; + } + return pos; + }); + } else if (key.delete) { + setText((t) => { + if (cursorPos < t.length) { + return t.slice(0, cursorPos) + t.slice(cursorPos + 1); + } + return t; + }); + } else if (shouldInsertInput(input, key)) { + setText(t => t.slice(0, cursorPos) + input + t.slice(cursorPos)); + setCursorPos(pos => pos + input.length); + } + }); + + let display = 'Enter rclone remote name: '; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char !== undefined) { + display += i === cursorPos ? `\x1b[7m${char}\x1b[0m` : char; + } + } + if (cursorPos >= text.length) { + display += '\x1b[7m \x1b[0m'; + } + + return ( + + {display} + ←→ move cursor, Enter save, ESC cancel (default: dropbox) + + ); +}; diff --git a/src/widgets/__tests__/RCloneQueue.test.tsx b/src/widgets/__tests__/RCloneQueue.test.tsx index 4385a7d5..5e2a3fb4 100644 --- a/src/widgets/__tests__/RCloneQueue.test.tsx +++ b/src/widgets/__tests__/RCloneQueue.test.tsx @@ -1,12 +1,16 @@ import fs from 'fs'; +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; import os from 'os'; import path from 'path'; +import stripAnsi from 'strip-ansi'; import { afterEach, beforeEach, describe, expect, - it + it, + vi } from 'vitest'; import type { @@ -255,3 +259,148 @@ describe('RCloneQueueWidget', () => { }); }); }); + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 120; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string } + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + return Object.assign(stream as unknown as NodeJS.WriteStream, { + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +function renderRemoteEditor(item: WidgetItem, onComplete = vi.fn(), onCancel = vi.fn()) { + const widget = new RCloneQueueWidget(); + const editorElement = widget.renderEditor({ widget: item, onComplete, onCancel, action: 'edit-remote' }); + + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render(editorElement, { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + }); + + return { instance, stdin, stdout, stderr, onComplete, onCancel }; +} + +function cleanupEditor(rendered: ReturnType): void { + rendered.instance.unmount(); + rendered.instance.cleanup(); + rendered.stdin.destroy(); + rendered.stdout.destroy(); + rendered.stderr.destroy(); +} + +function getPlainOutput(output: string): string { + return stripAnsi(output).replace(/\r\n/g, '\n'); +} + +describe('RCloneQueueWidget custom keybind', () => { + it('exposes an (e)dit remote keybind', () => { + const widget = new RCloneQueueWidget(); + expect(widget.getCustomKeybinds()).toEqual([ + { key: 'e', label: '(e)dit remote', action: 'edit-remote' } + ]); + }); +}); + +describe('RCloneRemoteEditor', () => { + it('shows the current remote name pre-filled', async () => { + const rendered = renderRemoteEditor({ id: 'rc', type: 'rclone-queue', metadata: { remoteName: 'gdrive' } }); + try { + await flushInk(); + const output = getPlainOutput(rendered.stdout.getOutput()); + expect(output).toContain('gdrive'); + } finally { + cleanupEditor(rendered); + } + }); + + it('saves the typed remote name on Enter', async () => { + const rendered = renderRemoteEditor({ id: 'rc', type: 'rclone-queue' }); + try { + await flushInk(); + // Backspace out "dropbox" (7 chars), then type "gdrive". + rendered.stdin.write('\b'.repeat(7)); + await flushInk(); + rendered.stdin.write('gdrive'); + await flushInk(); + rendered.stdin.write('\r'); + await flushInk(); + + const updated = rendered.onComplete.mock.calls[0]?.[0] as WidgetItem | undefined; + expect(updated?.metadata?.remoteName).toBe('gdrive'); + } finally { + cleanupEditor(rendered); + } + }); + + it('falls back to the default remote name when saved empty', async () => { + const rendered = renderRemoteEditor({ id: 'rc', type: 'rclone-queue' }); + try { + await flushInk(); + rendered.stdin.write('\b'.repeat(7)); + await flushInk(); + rendered.stdin.write('\r'); + await flushInk(); + + const updated = rendered.onComplete.mock.calls[0]?.[0] as WidgetItem | undefined; + expect(updated?.metadata?.remoteName).toBe('dropbox'); + } finally { + cleanupEditor(rendered); + } + }); + + it('cancels without calling onComplete on Escape', async () => { + const rendered = renderRemoteEditor({ id: 'rc', type: 'rclone-queue' }); + try { + await flushInk(); + rendered.stdin.write('\x1b'); + await flushInk(); + + expect(rendered.onComplete).not.toHaveBeenCalled(); + expect(rendered.onCancel).toHaveBeenCalledTimes(1); + } finally { + cleanupEditor(rendered); + } + }); +}); From 0fdf0e04a15aaabf561ee5c8555296ca57430a6c Mon Sep 17 00:00:00 2001 From: elhoim Date: Tue, 21 Jul 2026 21:12:38 +0000 Subject: [PATCH 07/13] fix(task-3): backspace input handling in RCloneRemoteEditor tests The previous test implementation batched all backspace writes into a single call using '\b'.repeat(7), causing Ink to coalesce them into one 'data' event that contained control characters. This was not recognized as individual key.backspace events, so the backspaces were ignored. Fixed by writing each backspace separately with an await/flush between each write, mirroring how a real keystroke stream arrives. This ensures each backspace is received as a separate key.backspace event by Ink's useInput hook. Fixes: - "saves the typed remote name on Enter" test now actually deletes the pre-filled text and correctly saves "gdrive" instead of "dropboxgdrive" - "falls back to the default remote name when saved empty" test now exercises the empty-string fallback logic instead of passing vacuously All 35 tests now pass (previously 34 pass / 1 fail). Co-Authored-By: Claude Sonnet 5 --- src/widgets/__tests__/RCloneQueue.test.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/widgets/__tests__/RCloneQueue.test.tsx b/src/widgets/__tests__/RCloneQueue.test.tsx index 5e2a3fb4..e450a23b 100644 --- a/src/widgets/__tests__/RCloneQueue.test.tsx +++ b/src/widgets/__tests__/RCloneQueue.test.tsx @@ -360,8 +360,11 @@ describe('RCloneRemoteEditor', () => { try { await flushInk(); // Backspace out "dropbox" (7 chars), then type "gdrive". - rendered.stdin.write('\b'.repeat(7)); - await flushInk(); + // Write each backspace separately with flush to ensure they arrive as individual key.backspace events + for (let i = 0; i < 7; i++) { + rendered.stdin.write('\b'); + await flushInk(); + } rendered.stdin.write('gdrive'); await flushInk(); rendered.stdin.write('\r'); @@ -378,8 +381,11 @@ describe('RCloneRemoteEditor', () => { const rendered = renderRemoteEditor({ id: 'rc', type: 'rclone-queue' }); try { await flushInk(); - rendered.stdin.write('\b'.repeat(7)); - await flushInk(); + // Write each backspace separately with flush to ensure they arrive as individual key.backspace events + for (let i = 0; i < 7; i++) { + rendered.stdin.write('\b'); + await flushInk(); + } rendered.stdin.write('\r'); await flushInk(); From a89b751c6c9e29ee28f17797b42a3606f6471920 Mon Sep 17 00:00:00 2001 From: elhoim Date: Tue, 21 Jul 2026 21:22:33 +0000 Subject: [PATCH 08/13] feat(widgets): register rclone-queue widget --- src/utils/widget-manifest.ts | 3 ++- src/widgets/index.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index efee81b0..5d710d12 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -103,7 +103,8 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'worktree-branch', create: () => new widgets.GitWorktreeBranchWidget() }, { type: 'worktree-original-branch', create: () => new widgets.GitWorktreeOriginalBranchWidget() }, { type: 'compaction-counter', create: () => new widgets.CompactionCounterWidget() }, - { type: 'cache-timer', create: () => new widgets.CacheTimerWidget() } + { type: 'cache-timer', create: () => new widgets.CacheTimerWidget() }, + { type: 'rclone-queue', create: () => new widgets.RCloneQueueWidget() } ]; export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [ diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 866ae0f7..d1a4959e 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -85,3 +85,4 @@ export { SandboxStatusWidget } from './SandboxStatus'; export { VoiceStatusWidget } from './VoiceStatus'; export { RemoteControlStatusWidget } from './RemoteControlStatus'; export { CacheTimerWidget } from './CacheTimer'; +export { RCloneQueueWidget } from './RCloneQueue'; From 62268099b2f3a636cd5c16ba523fb85bda1fbbb6 Mon Sep 17 00:00:00 2001 From: elhoim Date: Wed, 22 Jul 2026 09:19:19 +0000 Subject: [PATCH 09/13] fix(rclone-queue): use actual bytesRead instead of assuming full buffer fill fs.readSync can return fewer bytes than requested (e.g. the log file shrinks between fstatSync and readSync during a logrotate copytruncate). readLogTail ignored the return value and always stringified the full pre-allocated buffer, silently mixing stale zero-fill bytes into the most recent (tail) portion of the parsed log text. Co-Authored-By: Claude Sonnet 5 --- src/widgets/RCloneQueue.tsx | 5 ++-- src/widgets/__tests__/RCloneQueue.test.tsx | 29 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/widgets/RCloneQueue.tsx b/src/widgets/RCloneQueue.tsx index 3a47c299..572690f7 100644 --- a/src/widgets/RCloneQueue.tsx +++ b/src/widgets/RCloneQueue.tsx @@ -49,11 +49,12 @@ export function readLogTail(logPath: string, maxBytes: number = TAIL_BYTES): str const readSize = Math.min(size, maxBytes); const start = size - readSize; const buffer = Buffer.alloc(readSize); + let bytesRead = 0; if (readSize > 0) { - fs.readSync(fd, buffer, 0, readSize, start); + bytesRead = fs.readSync(fd, buffer, 0, readSize, start); } - let text = buffer.toString('utf8'); + let text = buffer.subarray(0, bytesRead).toString('utf8'); if (start > 0) { // The window may start mid-line; discard that partial first line. const firstNewline = text.indexOf('\n'); diff --git a/src/widgets/__tests__/RCloneQueue.test.tsx b/src/widgets/__tests__/RCloneQueue.test.tsx index e450a23b..903c759e 100644 --- a/src/widgets/__tests__/RCloneQueue.test.tsx +++ b/src/widgets/__tests__/RCloneQueue.test.tsx @@ -94,6 +94,35 @@ describe('readLogTail', () => { expect(tail).not.toContain('AAA'); expect(parseQueueLength(tail ?? '')).toBe(42); }); + + it('does not pad the result with stale/zero bytes when fewer bytes are read than requested', () => { + const logPath = path.join(tmpDir, 'short-read.log'); + fs.writeFileSync(logPath, 'to upload 5\n'); + + const realReadSync = fs.readSync.bind(fs); + const shortRead = (( + fdArg: number, + bufferArg: NodeJS.ArrayBufferView, + offsetArg: number, + _lengthArg: number, + positionArg: number | bigint | null + ): number => { + // Simulate a short read (e.g. the file shrank between fstatSync and readSync, + // as with logrotate copytruncate): only report a few bytes as actually read, + // even though a larger length was requested. + const shortLength = 6; + return realReadSync(fdArg, bufferArg, offsetArg, shortLength, positionArg); + }) as typeof fs.readSync; + const spy = vi.spyOn(fs, 'readSync').mockImplementationOnce(shortRead); + + try { + const tail = readLogTail(logPath, 1024); + expect(tail).toBe('to upl'); + expect(tail?.length).toBe(6); + } finally { + spy.mockRestore(); + } + }); }); describe('getQueueLength (cache)', () => { From b2083567b076159da107063dd76d4b833138e931 Mon Sep 17 00:00:00 2001 From: elhoim Date: Wed, 22 Jul 2026 09:25:30 +0000 Subject: [PATCH 10/13] fix(rclone-queue): persist the queue cache to disk so it survives across statusline invocations ccstatusline's piped mode runs as a fresh process per statusline refresh, so the in-process Map cache was always empty on every invocation and never actually avoided the 64KB tail-read + regex re-parse it was meant to save, contrary to the design's stated intent. Mirror src/utils/git.ts's pattern: pair the in-process cache with a persistent JSON cache under ~/.cache/ccstatusline/, same 15s TTL, so a fresh process can reuse a still-fresh reading instead of re-parsing the log. Co-Authored-By: Claude Sonnet 5 --- src/widgets/RCloneQueue.tsx | 81 +++++++++++++++++++++- src/widgets/__tests__/RCloneQueue.test.tsx | 46 ++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/widgets/RCloneQueue.tsx b/src/widgets/RCloneQueue.tsx index 572690f7..11bcf8d8 100644 --- a/src/widgets/RCloneQueue.tsx +++ b/src/widgets/RCloneQueue.tsx @@ -30,8 +30,65 @@ interface QueueCacheEntry { createdAt: number; } +const QUEUE_CACHE_SCHEMA_VERSION = 1 as const; + +interface PersistentQueueCache { + version: typeof QUEUE_CACHE_SCHEMA_VERSION; + entries: Record; +} + const queueCache = new Map(); +function getPersistentCachePath(): string { + return path.join(os.homedir(), '.cache', 'ccstatusline', 'rclone-queue-cache.json'); +} + +function isQueueCacheEntry(value: unknown): value is QueueCacheEntry { + if (typeof value !== 'object' || value === null) { + return false; + } + + const entry = value as Record; + return (typeof entry.value === 'number' || entry.value === null) && typeof entry.createdAt === 'number'; +} + +function readPersistentQueueCache(): PersistentQueueCache | null { + try { + const parsed = JSON.parse(fs.readFileSync(getPersistentCachePath(), 'utf-8')) as unknown; + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + + const data = parsed as { version?: unknown; entries?: unknown }; + if (data.version !== QUEUE_CACHE_SCHEMA_VERSION || typeof data.entries !== 'object' || data.entries === null) { + return null; + } + + const entries: Record = {}; + for (const [key, value] of Object.entries(data.entries)) { + if (isQueueCacheEntry(value)) { + entries[key] = value; + } + } + + return { version: QUEUE_CACHE_SCHEMA_VERSION, entries }; + } catch { + return null; + } +} + +function writePersistentQueueCache(cache: PersistentQueueCache): void { + try { + const cachePath = getPersistentCachePath(); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + const tempPath = `${cachePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(cache), 'utf-8'); + fs.renameSync(tempPath, cachePath); + } catch { + // Best-effort cache; statusline rendering should never fail because of it. + } +} + export function getRcloneLogPath(remoteName: string): string { return path.join(os.homedir(), '.cache', 'rclone', `${remoteName}.log`); } @@ -91,15 +148,37 @@ export function getQueueLength(remoteName: string, now: number = Date.now()): nu return cached.value; } + // ccstatusline's primary (piped) mode runs as a fresh process per statusline + // refresh, so the in-process cache above is empty on every invocation. Fall + // back to a persistent on-disk cache (same TTL) so the 64KB tail-read+regex + // work is actually skipped across refreshes, not just within one process. + const persistentCache = readPersistentQueueCache(); + const persistentEntry = persistentCache?.entries[remoteName]; + if (persistentEntry && now - persistentEntry.createdAt < CACHE_TTL_MS) { + queueCache.set(remoteName, persistentEntry); + return persistentEntry.value; + } + const logPath = getRcloneLogPath(remoteName); const text = readLogTail(logPath); const value = text === null ? null : parseQueueLength(text); - queueCache.set(remoteName, { value, createdAt: now }); + const entry: QueueCacheEntry = { value, createdAt: now }; + queueCache.set(remoteName, entry); + + const cache = persistentCache ?? { version: QUEUE_CACHE_SCHEMA_VERSION, entries: {} }; + cache.entries[remoteName] = entry; + writePersistentQueueCache(cache); + return value; } export function clearRCloneQueueCache(): void { queueCache.clear(); + try { + fs.rmSync(getPersistentCachePath(), { force: true }); + } catch { + // Best-effort cleanup; only used by tests to avoid cross-run pollution. + } } export function getRemoteName(item: WidgetItem): string { diff --git a/src/widgets/__tests__/RCloneQueue.test.tsx b/src/widgets/__tests__/RCloneQueue.test.tsx index 903c759e..1eea1e6b 100644 --- a/src/widgets/__tests__/RCloneQueue.test.tsx +++ b/src/widgets/__tests__/RCloneQueue.test.tsx @@ -176,6 +176,52 @@ describe('getQueueLength (cache)', () => { }); }); +describe('getQueueLength (persistent cache)', () => { + let home: string; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'rclone-queue-cache-home-')); + vi.spyOn(os, 'homedir').mockReturnValue(home); + }); + + afterEach(() => { + clearRCloneQueueCache(); + vi.restoreAllMocks(); + fs.rmSync(home, { recursive: true, force: true }); + }); + + it('writes a persistent on-disk entry so a value can survive a fresh process', () => { + const remoteName = 'persisted-remote'; + const logPath = getRcloneLogPath(remoteName); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.writeFileSync(logPath, 'INFO : vfs cache: cleaned: in use 10, to upload 11, uploading 1, total size 1Gi\n'); + + expect(getQueueLength(remoteName, 1000)).toBe(11); + + const cachePath = path.join(home, '.cache', 'ccstatusline', 'rclone-queue-cache.json'); + const persisted = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as { entries?: Record }; + expect(persisted.entries?.[remoteName]).toEqual({ value: 11, createdAt: 1000 }); + }); + + it('serves a value from a pre-existing persistent cache entry without reading the log file', () => { + // ccstatusline's piped mode runs as a fresh process per statusline refresh, so + // the in-process Map cache above is always empty at this point; this reproduces + // that by never calling getQueueLength for this remote before seeding the + // on-disk cache directly. + const remoteName = 'preseeded-remote'; + const cachePath = path.join(home, '.cache', 'ccstatusline', 'rclone-queue-cache.json'); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify({ + version: 1, + entries: { [remoteName]: { value: 42, createdAt: 1000 } } + })); + + // No log file exists for this remote at all: if the persistent cache weren't + // consulted, this would fall through to readLogTail and return null instead. + expect(getQueueLength(remoteName, 1000 + CACHE_TTL_MS - 1)).toBe(42); + }); +}); + describe('RCloneQueueWidget', () => { const remoteName = 'test-remote-widget'; const logPath = getRcloneLogPath(remoteName); From b219658c8b1678c63640ff07ccf0287aa48d0c63 Mon Sep 17 00:00:00 2001 From: elhoim Date: Wed, 22 Jul 2026 09:28:36 +0000 Subject: [PATCH 11/13] fix(rclone-queue): fall back to the default remote when metadata.remoteName is blank getRemoteName used ?? so it only fell back to DEFAULT_REMOTE_NAME for null/undefined metadata, not for an empty or whitespace-only string (a value the metadata schema happily accepts). That diverged from the editor's own save-time fallback, so a blank remoteName reaching metadata through any other path (manual settings edit, future import/migration) resolved to a log path that could never exist. Co-Authored-By: Claude Sonnet 5 --- src/widgets/RCloneQueue.tsx | 3 ++- src/widgets/__tests__/RCloneQueue.test.tsx | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/widgets/RCloneQueue.tsx b/src/widgets/RCloneQueue.tsx index 11bcf8d8..9f83f31c 100644 --- a/src/widgets/RCloneQueue.tsx +++ b/src/widgets/RCloneQueue.tsx @@ -182,7 +182,8 @@ export function clearRCloneQueueCache(): void { } export function getRemoteName(item: WidgetItem): string { - return item.metadata?.remoteName ?? DEFAULT_REMOTE_NAME; + const remoteName = item.metadata?.remoteName?.trim(); + return remoteName && remoteName.length > 0 ? remoteName : DEFAULT_REMOTE_NAME; } export class RCloneQueueWidget implements Widget { diff --git a/src/widgets/__tests__/RCloneQueue.test.tsx b/src/widgets/__tests__/RCloneQueue.test.tsx index 1eea1e6b..7170285d 100644 --- a/src/widgets/__tests__/RCloneQueue.test.tsx +++ b/src/widgets/__tests__/RCloneQueue.test.tsx @@ -332,6 +332,14 @@ describe('RCloneQueueWidget', () => { it('uses the configured remote name from metadata', () => { expect(getRemoteName({ id: 'rc', type: 'rclone-queue', metadata: { remoteName: 'gdrive' } })).toBe('gdrive'); }); + + it('falls back to the default remote name when metadata.remoteName is an empty string', () => { + expect(getRemoteName({ id: 'rc', type: 'rclone-queue', metadata: { remoteName: '' } })).toBe('dropbox'); + }); + + it('falls back to the default remote name when metadata.remoteName is whitespace only', () => { + expect(getRemoteName({ id: 'rc', type: 'rclone-queue', metadata: { remoteName: ' ' } })).toBe('dropbox'); + }); }); }); From 0e1f324416fba7253fc43788712dce436c71d7b6 Mon Sep 17 00:00:00 2001 From: elhoim Date: Wed, 22 Jul 2026 09:31:27 +0000 Subject: [PATCH 12/13] fix(rclone-queue): sanitize remoteName to prevent path traversal outside the rclone cache dir The remote-name editor only blocked control characters, so a remoteName containing "../" segments made getRcloneLogPath resolve outside ~/.cache/rclone/, letting the widget tail-read and display digits from arbitrary *.log files reachable by relative traversal. path.basename strips any directory separators before the path is joined, so the result can never escape the intended cache directory. Co-Authored-By: Claude Sonnet 5 --- src/widgets/RCloneQueue.tsx | 5 ++++- src/widgets/__tests__/RCloneQueue.test.tsx | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/widgets/RCloneQueue.tsx b/src/widgets/RCloneQueue.tsx index 9f83f31c..db0eebf5 100644 --- a/src/widgets/RCloneQueue.tsx +++ b/src/widgets/RCloneQueue.tsx @@ -90,7 +90,10 @@ function writePersistentQueueCache(cache: PersistentQueueCache): void { } export function getRcloneLogPath(remoteName: string): string { - return path.join(os.homedir(), '.cache', 'rclone', `${remoteName}.log`); + // path.basename strips any directory separators (including "../" traversal + // segments), so a remoteName sourced from freely-typed metadata can never + // resolve outside ~/.cache/rclone/. + return path.join(os.homedir(), '.cache', 'rclone', `${path.basename(remoteName)}.log`); } export function readLogTail(logPath: string, maxBytes: number = TAIL_BYTES): string | null { diff --git a/src/widgets/__tests__/RCloneQueue.test.tsx b/src/widgets/__tests__/RCloneQueue.test.tsx index 7170285d..523a63cd 100644 --- a/src/widgets/__tests__/RCloneQueue.test.tsx +++ b/src/widgets/__tests__/RCloneQueue.test.tsx @@ -174,6 +174,16 @@ describe('getQueueLength (cache)', () => { it('derives the log path from ~/.cache/rclone/.log', () => { expect(getRcloneLogPath('gdrive')).toBe(path.join(os.homedir(), '.cache', 'rclone', 'gdrive.log')); }); + + it('strips directory-traversal segments from remoteName instead of escaping the cache dir', () => { + const traversal = getRcloneLogPath('../../../etc/some-secrets'); + expect(traversal).toBe(path.join(os.homedir(), '.cache', 'rclone', 'some-secrets.log')); + }); + + it('confines an absolute-path-like remoteName to the cache dir', () => { + const absolute = getRcloneLogPath('/etc/passwd'); + expect(absolute).toBe(path.join(os.homedir(), '.cache', 'rclone', 'passwd.log')); + }); }); describe('getQueueLength (persistent cache)', () => { From 06d0c7ec3588957db3d4f04ba4d1bb6eaba98fff Mon Sep 17 00:00:00 2001 From: elhoim Date: Wed, 22 Jul 2026 09:35:30 +0000 Subject: [PATCH 13/13] fix(rclone-queue): use grapheme-aware cursor/edit indexing in the remote-name editor RCloneRemoteEditor indexed the remote-name string with plain UTF-16 code units (text[i], slice(pos-1)), unlike CustomText.tsx's editor for the same interaction. A character outside the BMP (e.g. an emoji) could split a surrogate pair on backspace/delete, corrupt the cursor highlight, and leave an unpaired surrogate saved into metadata.remoteName. Reuse the same Intl.Segmenter-based grapheme-index helpers CustomText.tsx already uses. Co-Authored-By: Claude Sonnet 5 --- src/widgets/RCloneQueue.tsx | 87 +++++++++++++++++----- src/widgets/__tests__/RCloneQueue.test.tsx | 18 +++++ 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/src/widgets/RCloneQueue.tsx b/src/widgets/RCloneQueue.tsx index db0eebf5..eaa7c905 100644 --- a/src/widgets/RCloneQueue.tsx +++ b/src/widgets/RCloneQueue.tsx @@ -226,6 +226,43 @@ export class RCloneQueueWidget implements Widget { } } +// Grapheme-aware text editing (mirrors CustomText.tsx's editor), so a +// remote name containing a multi-code-unit character (e.g. an emoji) can't +// desync the cursor or split a surrogate pair on backspace/delete. +function getGraphemes(str: string): string[] { + if ('Segmenter' in Intl) { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + return Array.from(segmenter.segment(str), seg => seg.segment); + } + return Array.from(str); +} + +function graphemeToStringIndex(str: string, graphemeIndex: number): number { + const graphemes = getGraphemes(str); + let stringIndex = 0; + for (let i = 0; i < Math.min(graphemeIndex, graphemes.length); i++) { + const grapheme = graphemes[i]; + if (grapheme) { + stringIndex += grapheme.length; + } + } + return stringIndex; +} + +function stringToGraphemeIndex(str: string, stringIndex: number): number { + const graphemes = getGraphemes(str); + let currentStringIndex = 0; + for (let i = 0; i < graphemes.length; i++) { + if (currentStringIndex >= stringIndex) + return i; + const grapheme = graphemes[i]; + if (grapheme) { + currentStringIndex += grapheme.length; + } + } + return graphemes.length; +} + const RCloneRemoteEditor: React.FC = ({ widget, onComplete, onCancel }) => { const [text, setText] = useState(getRemoteName(widget)); const [cursorPos, setCursorPos] = useState(text.length); @@ -240,38 +277,48 @@ const RCloneRemoteEditor: React.FC = ({ widget, onComplete, o } else if (key.escape) { onCancel(); } else if (key.leftArrow) { - setCursorPos(pos => Math.max(0, pos - 1)); + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + if (currentGraphemeIndex > 0) { + setCursorPos(graphemeToStringIndex(text, currentGraphemeIndex - 1)); + } } else if (key.rightArrow) { - setCursorPos(pos => Math.min(text.length, pos + 1)); + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + const graphemeCount = getGraphemes(text).length; + if (currentGraphemeIndex < graphemeCount) { + setCursorPos(graphemeToStringIndex(text, currentGraphemeIndex + 1)); + } } else if (key.backspace) { - setCursorPos((pos) => { - if (pos > 0) { - setText(t => t.slice(0, pos - 1) + t.slice(pos)); - return pos - 1; - } - return pos; - }); + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + if (currentGraphemeIndex > 0) { + const deleteFromIndex = graphemeToStringIndex(text, currentGraphemeIndex - 1); + const deleteToIndex = graphemeToStringIndex(text, currentGraphemeIndex); + setText(t => t.slice(0, deleteFromIndex) + t.slice(deleteToIndex)); + setCursorPos(deleteFromIndex); + } } else if (key.delete) { - setText((t) => { - if (cursorPos < t.length) { - return t.slice(0, cursorPos) + t.slice(cursorPos + 1); - } - return t; - }); + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + const graphemeCount = getGraphemes(text).length; + if (currentGraphemeIndex < graphemeCount) { + const deleteFromIndex = graphemeToStringIndex(text, currentGraphemeIndex); + const deleteToIndex = graphemeToStringIndex(text, currentGraphemeIndex + 1); + setText(t => t.slice(0, deleteFromIndex) + t.slice(deleteToIndex)); + } } else if (shouldInsertInput(input, key)) { setText(t => t.slice(0, cursorPos) + input + t.slice(cursorPos)); setCursorPos(pos => pos + input.length); } }); + const graphemes = getGraphemes(text); + const cursorGraphemeIndex = stringToGraphemeIndex(text, cursorPos); let display = 'Enter rclone remote name: '; - for (let i = 0; i < text.length; i++) { - const char = text[i]; - if (char !== undefined) { - display += i === cursorPos ? `\x1b[7m${char}\x1b[0m` : char; + for (let i = 0; i < graphemes.length; i++) { + const grapheme = graphemes[i]; + if (grapheme !== undefined) { + display += i === cursorGraphemeIndex ? `\x1b[7m${grapheme}\x1b[0m` : grapheme; } } - if (cursorPos >= text.length) { + if (cursorGraphemeIndex >= graphemes.length) { display += '\x1b[7m \x1b[0m'; } diff --git a/src/widgets/__tests__/RCloneQueue.test.tsx b/src/widgets/__tests__/RCloneQueue.test.tsx index 523a63cd..88a3f334 100644 --- a/src/widgets/__tests__/RCloneQueue.test.tsx +++ b/src/widgets/__tests__/RCloneQueue.test.tsx @@ -502,4 +502,22 @@ describe('RCloneRemoteEditor', () => { cleanupEditor(rendered); } }); + + it('deletes a whole multi-code-unit emoji on a single backspace instead of splitting the surrogate pair', async () => { + // gdrive📁 has one BMP character worth of "emoji folder" appended; + // a naive text[i]/UTF-16-index editor would delete only half of it. + const rendered = renderRemoteEditor({ id: 'rc', type: 'rclone-queue', metadata: { remoteName: 'gdrive📁' } }); + try { + await flushInk(); + rendered.stdin.write('\b'); + await flushInk(); + rendered.stdin.write('\r'); + await flushInk(); + + const updated = rendered.onComplete.mock.calls[0]?.[0] as WidgetItem | undefined; + expect(updated?.metadata?.remoteName).toBe('gdrive'); + } finally { + cleanupEditor(rendered); + } + }); });