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. 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..c8a1a933 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-rclone-queue-widget-design.md @@ -0,0 +1,70 @@ +# 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 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 + +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. 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/RCloneQueue.tsx b/src/widgets/RCloneQueue.tsx new file mode 100644 index 00000000..eaa7c905 --- /dev/null +++ b/src/widgets/RCloneQueue.tsx @@ -0,0 +1,331 @@ +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+)/; + +interface QueueCacheEntry { + value: number | null; + 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 { + // 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 { + 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); + let bytesRead = 0; + if (readSize > 0) { + bytesRead = fs.readSync(fd, buffer, 0, readSize, start); + } + + 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'); + 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; + } + + // 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); + 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 { + const remoteName = item.metadata?.remoteName?.trim(); + return remoteName && remoteName.length > 0 ? 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)})` }; + } + + 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'; + } + + 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; } + + renderEditor(props: WidgetEditorProps): React.ReactElement { + return ; + } +} + +// 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); + + 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) { + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + if (currentGraphemeIndex > 0) { + setCursorPos(graphemeToStringIndex(text, currentGraphemeIndex - 1)); + } + } else if (key.rightArrow) { + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + const graphemeCount = getGraphemes(text).length; + if (currentGraphemeIndex < graphemeCount) { + setCursorPos(graphemeToStringIndex(text, currentGraphemeIndex + 1)); + } + } else if (key.backspace) { + 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) { + 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 < graphemes.length; i++) { + const grapheme = graphemes[i]; + if (grapheme !== undefined) { + display += i === cursorGraphemeIndex ? `\x1b[7m${grapheme}\x1b[0m` : grapheme; + } + } + if (cursorGraphemeIndex >= graphemes.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 new file mode 100644 index 00000000..88a3f334 --- /dev/null +++ b/src/widgets/__tests__/RCloneQueue.test.tsx @@ -0,0 +1,523 @@ +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, + vi +} 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'; + +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); + }); + + 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)', () => { + 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')); + }); + + 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)', () => { + 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); + + 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'); + }); + + 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'); + }); + }); +}); + +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". + // 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'); + 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(); + // 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(); + + 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); + } + }); + + 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); + } + }); +}); 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';