From 386237508f14c238241554b267a62e956d1e93bd Mon Sep 17 00:00:00 2001 From: Kritarth-Dandapat Date: Sat, 12 Sep 2026 11:45:26 -0400 Subject: [PATCH 1/2] feat(library): add recoverable trash and explicit permanent purge [L2-04] --- core/library/repo.test.ts | 124 +++++++++++++++++- core/library/repo.ts | 66 +++++++++- core/store/db.test.ts | 2 +- core/store/migrate.test.ts | 17 ++- core/store/migrate.ts | 3 +- core/store/schema.ts | 6 + electron/main.ts | 18 ++- electron/preload.ts | 4 + package.json | 1 + src/app/App.test.tsx | 12 ++ src/app/App.tsx | 17 ++- src/app/Library.module.css | 47 +++++++ src/app/Library.test.tsx | 23 +++- src/app/Library.tsx | 71 ++++++++--- src/app/Sidebar.test.tsx | 11 +- src/app/Sidebar.tsx | 12 +- src/app/TrashView.module.css | 235 +++++++++++++++++++++++++++++++++++ src/app/TrashView.test.tsx | 124 ++++++++++++++++++ src/app/TrashView.tsx | 175 ++++++++++++++++++++++++++ test/trash-live.mjs | 165 ++++++++++++++++++++++++ 20 files changed, 1100 insertions(+), 33 deletions(-) create mode 100644 src/app/TrashView.module.css create mode 100644 src/app/TrashView.test.tsx create mode 100644 src/app/TrashView.tsx create mode 100644 test/trash-live.mjs diff --git a/core/library/repo.test.ts b/core/library/repo.test.ts index 99860f9..2d7091f 100644 --- a/core/library/repo.test.ts +++ b/core/library/repo.test.ts @@ -1,7 +1,10 @@ +import os from 'node:os' +import fs from 'node:fs' +import path from 'node:path' import type { Database } from 'better-sqlite3' import { describe, expect, it } from 'vitest' import { openDb } from '../store/db.js' -import { getPaper, listPapers, upsertPaper } from './repo.js' +import { getPaper, listPapers, upsertPaper, trashPaper, restorePaper, purgePaper } from './repo.js' import type { PaperRecord } from './repo.js' function makeRecord(overrides: Partial = {}): PaperRecord { @@ -267,4 +270,123 @@ describe('repo', () => { } }) }) + + describe('Trash and Purge operations [L2-04]', () => { +function seedDb(db: Database): void { + upsertPaper( + db, + makeRecord({ + slug: 'attention', + title: 'Attention Is All You Need', + year: 2017, + addedAt: '2026-01-01T00:00:00.000Z', + }), + ) + upsertPaper( + db, + makeRecord({ + slug: 'bert', + title: 'BERT: Pre-training of Deep Bidirectional Transformers', + year: 2019, + addedAt: '2026-03-01T00:00:00.000Z', + }), + ) + upsertPaper( + db, + makeRecord({ + slug: 'gpt3', + title: 'Language Models are Few-Shot Learners', + year: 2020, + addedAt: '2026-02-01T00:00:00.000Z', + }), + ) +} + + it('trashes a paper, hiding it from default library while retaining it in trashed view', () => { + const db = openDb({ path: ':memory:' }) + try { + seedDb(db) + expect(listPapers(db)).toHaveLength(3) + + // Trash 'bert' + const trashed = trashPaper(db, 'bert') + expect(trashed).toBeDefined() + expect(trashed?.slug).toBe('bert') + expect(trashed?.trashedAt).toBeTruthy() + + // Default list excludes trashed + const active = listPapers(db) + expect(active.map((p) => p.slug)).toEqual(['gpt3', 'attention']) + + // Trashed list includes bert + const trashList = listPapers(db, { trashed: true }) + expect(trashList.map((p) => p.slug)).toEqual(['bert']) + } finally { + db.close() + } + }) + + it('restores a trashed paper back to active Library', () => { + const db = openDb({ path: ':memory:' }) + try { + seedDb(db) + trashPaper(db, 'bert') + expect(listPapers(db)).toHaveLength(2) + + const restored = restorePaper(db, 'bert') + expect(restored?.slug).toBe('bert') + expect(restored?.trashedAt).toBeUndefined() + + const active = listPapers(db) + expect(active.map((p) => p.slug)).toContain('bert') + expect(listPapers(db, { trashed: true })).toHaveLength(0) + } finally { + db.close() + } + }) + + it('permanently purges a paper from DB and removes its directory on disk', () => { + const db = openDb({ path: ':memory:' }) + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vellum-purge-test-')) + try { + const paperDir = path.join(tmpDir, 'papers', 'purge-me') + fs.mkdirSync(paperDir, { recursive: true }) + fs.writeFileSync(path.join(paperDir, 'content.pdf'), 'fake pdf') + + upsertPaper(db, makeRecord({ slug: 'purge-me', title: 'Paper To Purge' })) + expect(getPaper(db, 'purge-me')).toBeDefined() + + const purged = purgePaper(db, 'purge-me', tmpDir) + expect(purged).toBe(true) + expect(getPaper(db, 'purge-me')).toBeUndefined() + expect(fs.existsSync(paperDir)).toBe(false) + } finally { + db.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('purgePaper safely handles missing on-disk files without throwing', () => { + const db = openDb({ path: ':memory:' }) + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vellum-purge-missing-')) + try { + upsertPaper(db, makeRecord({ slug: 'non-existent-files', title: 'Ghost Paper' })) + const purged = purgePaper(db, 'non-existent-files', tmpDir) + expect(purged).toBe(true) + expect(getPaper(db, 'non-existent-files')).toBeUndefined() + } finally { + db.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('purgePaper prevents path traversal in slug', () => { + const db = openDb({ path: ':memory:' }) + try { + expect(() => purgePaper(db, '../traversal')).toThrow(/Invalid slug format/) + } finally { + db.close() + } + }) + }) }) diff --git a/core/library/repo.ts b/core/library/repo.ts index 455ab0c..9fd0403 100644 --- a/core/library/repo.ts +++ b/core/library/repo.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs' +import path from 'node:path' // Paper CRUD against the `papers` table (core/store/schema.ts SCHEMA_V1). // Owns all SQL for the papers table — parameterized statements only, no // string interpolation of caller-supplied values. This is the storage seam @@ -34,6 +36,8 @@ export interface PaperRecord { /** Section outline from core/ingest/extract.ts's ExtractResult.sections. */ sections?: unknown[] addedAt: string + /** [L2-04] Timestamp when paper was moved to Trash; undefined/null if active. */ + trashedAt?: string | null } interface PaperRow { @@ -51,6 +55,7 @@ interface PaperRow { pdf_path: string | null sections: string | null added_at: string + trashed_at: string | null } function toRecord(row: PaperRow): PaperRecord { @@ -71,6 +76,7 @@ function toRecord(row: PaperRow): PaperRecord { pdfPath: row.pdf_path ?? undefined, sections: row.sections ? (JSON.parse(row.sections) as unknown[]) : [], addedAt: row.added_at, + trashedAt: row.trashed_at ?? undefined, } } @@ -81,8 +87,8 @@ function toRecord(row: PaperRow): PaperRecord { */ export function upsertPaper(db: Database, paper: PaperRecord): void { db.prepare( - `INSERT INTO papers (slug, title, authors, author_orcids, year, venue, doi, arxiv_id, abstract, summary, md_path, pdf_path, sections, added_at) - VALUES (@slug, @title, @authors, @authorOrcids, @year, @venue, @doi, @arxivId, @abstract, @summary, @mdPath, @pdfPath, @sections, @addedAt) + `INSERT INTO papers (slug, title, authors, author_orcids, year, venue, doi, arxiv_id, abstract, summary, md_path, pdf_path, sections, added_at, trashed_at) + VALUES (@slug, @title, @authors, @authorOrcids, @year, @venue, @doi, @arxivId, @abstract, @summary, @mdPath, @pdfPath, @sections, @addedAt, @trashedAt) ON CONFLICT(slug) DO UPDATE SET title = excluded.title, authors = excluded.authors, @@ -96,7 +102,8 @@ export function upsertPaper(db: Database, paper: PaperRecord): void { md_path = excluded.md_path, pdf_path = excluded.pdf_path, sections = excluded.sections, - added_at = excluded.added_at`, + added_at = excluded.added_at, + trashed_at = excluded.trashed_at`, ).run({ slug: paper.slug, title: paper.title, @@ -112,6 +119,7 @@ export function upsertPaper(db: Database, paper: PaperRecord): void { pdfPath: paper.pdfPath ?? null, sections: JSON.stringify(paper.sections ?? []), addedAt: paper.addedAt, + trashedAt: paper.trashedAt ?? null, }) } @@ -147,6 +155,8 @@ export interface ListPapersOptions { sort?: PaperSortColumn /** Sort direction. Defaults to `desc`. */ order?: 'asc' | 'desc' + /** [L2-04] If true, lists only trashed papers. If false/omitted, excludes trashed papers. */ + trashed?: boolean } /** Escape SQLite LIKE metacharacters (`%`, `_`) plus the escape char itself @@ -176,6 +186,12 @@ export function listPapers(db: Database, options: ListPapersOptions = {}): Paper const params: unknown[] = [] const conditions: string[] = [] + if (options.trashed) { + conditions.push('papers.trashed_at IS NOT NULL') + } else { + conditions.push('papers.trashed_at IS NULL') + } + if (collectionId !== undefined) { sql += ' JOIN paper_collections pc ON pc.paper_slug = papers.slug' conditions.push('pc.collection_id = ?') @@ -196,3 +212,47 @@ export function listPapers(db: Database, options: ListPapersOptions = {}): Paper const rows = db.prepare(sql).all(...params) as PaperRow[] return rows.map(toRecord) } + +/** [L2-04] Move a paper to Trash (soft delete). */ +export function trashPaper(db: Database, slug: string): PaperRecord | undefined { + const now = new Date().toISOString() + db.prepare('UPDATE papers SET trashed_at = ? WHERE slug = ?').run(now, slug) + return getPaper(db, slug) +} + +/** [L2-04] Restore a paper from Trash back to active Library. */ +export function restorePaper(db: Database, slug: string): PaperRecord | undefined { + db.prepare('UPDATE papers SET trashed_at = NULL WHERE slug = ?').run(slug) + return getPaper(db, slug) +} + +/** + * [L2-04] Permanently purge a paper from SQLite and delete its on-disk assets. + * Cascades to notes, highlights, chats, collections, and questions. + */ +export function purgePaper(db: Database, slug: string, libraryDir?: string): boolean { + if (!/^[a-z0-9-]+$/i.test(slug)) { + throw new Error(`Invalid slug format: ${slug}`) + } + + const paper = getPaper(db, slug) + if (!paper) return false + + if (libraryDir) { + const targetDir = path.resolve(libraryDir, 'papers', slug) + const papersRoot = path.resolve(libraryDir, 'papers') + if (!targetDir.startsWith(papersRoot + path.sep)) { + throw new Error(`Path traversal detected: ${targetDir}`) + } + try { + if (fs.existsSync(targetDir)) { + fs.rmSync(targetDir, { recursive: true, force: true }) + } + } catch (err) { + throw new Error(`Failed to remove paper files from disk: ${err instanceof Error ? err.message : String(err)}`) + } + } + + const result = db.prepare('DELETE FROM papers WHERE slug = ?').run(slug) + return result.changes > 0 +} diff --git a/core/store/db.test.ts b/core/store/db.test.ts index 7aed5ce..9d04643 100644 --- a/core/store/db.test.ts +++ b/core/store/db.test.ts @@ -60,7 +60,7 @@ describe('openDb', () => { const row = db2.prepare('SELECT * FROM papers WHERE slug = ?').get('p1') expect(row).toBeTruthy() const version = db2.pragma('user_version', { simple: true }) - expect(version).toBe(6) + expect(version).toBe(7) } finally { db2.close() } diff --git a/core/store/migrate.test.ts b/core/store/migrate.test.ts index 07f66e3..721dd6a 100644 --- a/core/store/migrate.test.ts +++ b/core/store/migrate.test.ts @@ -38,7 +38,7 @@ describe('runMigrations', () => { runMigrations(db) const version = db.pragma('user_version', { simple: true }) - expect(version).toBe(6) + expect(version).toBe(7) }) it('adds the author_orcids column to papers [P2-04]', () => { @@ -77,7 +77,7 @@ describe('runMigrations', () => { runMigrations(db) const version = db.pragma('user_version', { simple: true }) - expect(version).toBe(6) + expect(version).toBe(7) const row = db.prepare('SELECT * FROM papers WHERE slug = ?').get('a') expect(row).toBeTruthy() @@ -101,6 +101,19 @@ describe('runMigrations', () => { expect(indexes).toContain('idx_suggested_questions_paper') }) + + it('adds the trashed_at column to papers [L2-04]', () => { + db = new Database(':memory:') + runMigrations(db) + + const columns = db + .prepare("PRAGMA table_info(papers)") + .all() + .map((row) => (row as { name: string }).name) + + expect(columns).toContain('trashed_at') + }) + it('applies only migrations newer than the current version, in order', () => { db = new Database(':memory:') diff --git a/core/store/migrate.ts b/core/store/migrate.ts index 59dadac..77da14e 100644 --- a/core/store/migrate.ts +++ b/core/store/migrate.ts @@ -12,7 +12,7 @@ import type { Database } from 'better-sqlite3' -import { SCHEMA_V1, SCHEMA_V3_NOTES, SCHEMA_V4_HIGHLIGHTS, SCHEMA_V5_AUTHOR_ORCIDS, SCHEMA_V6_SUGGESTED_QUESTIONS } from './schema.js' +import { SCHEMA_V1, SCHEMA_V3_NOTES, SCHEMA_V4_HIGHLIGHTS, SCHEMA_V5_AUTHOR_ORCIDS, SCHEMA_V6_SUGGESTED_QUESTIONS, SCHEMA_V7_TRASH } from './schema.js' export interface Migration { version: number @@ -27,6 +27,7 @@ export const MIGRATIONS: Migration[] = [ { version: 4, sql: SCHEMA_V4_HIGHLIGHTS }, // [P2-02] highlights table { version: 5, sql: SCHEMA_V5_AUTHOR_ORCIDS }, // [P2-04] author_orcids column { version: 6, sql: SCHEMA_V6_SUGGESTED_QUESTIONS }, // [L2-03] suggested_questions table + { version: 7, sql: SCHEMA_V7_TRASH }, // [L2-04] trashed_at column ] /** diff --git a/core/store/schema.ts b/core/store/schema.ts index f59ee2a..5d96aa2 100644 --- a/core/store/schema.ts +++ b/core/store/schema.ts @@ -123,3 +123,9 @@ CREATE TABLE IF NOT EXISTS suggested_questions ( ); CREATE INDEX IF NOT EXISTS idx_suggested_questions_paper ON suggested_questions(paper_slug, backend); ` + +// [L2-04] Recoverable Trash: nullable trashed_at timestamp on papers. +// NULL means active in Library. Non-NULL means moved to Trash. +export const SCHEMA_V7_TRASH = ` +ALTER TABLE papers ADD COLUMN trashed_at TEXT; +` diff --git a/electron/main.ts b/electron/main.ts index 6bed46e..ec944bb 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -23,7 +23,7 @@ import { ChatManager } from '../core/chat/manager.js' import { getChatSession } from '../core/chat/repo.js' import { ingest } from '../core/ingest/index.js' import type { IngestResult } from '../core/ingest/index.js' -import { getPaper, listPapers } from '../core/library/repo.js' +import { getPaper, listPapers, trashPaper, restorePaper, purgePaper } from '../core/library/repo.js' import type { ListPapersOptions, PaperRecord, PaperSortColumn } from '../core/library/repo.js' import { deleteNote, getNote, upsertNote } from '../core/notes/repo.js' import { @@ -149,8 +149,9 @@ function toListPapersOptions(value: unknown): ListPapersOptions { : undefined const order = candidate['order'] === 'asc' || candidate['order'] === 'desc' ? candidate['order'] : undefined const collectionId = typeof candidate['collectionId'] === 'number' ? candidate['collectionId'] : undefined + const trashed = typeof candidate['trashed'] === 'boolean' ? candidate['trashed'] : undefined - return { search, collectionId, sort, order } + return { search, collectionId, sort, order, trashed } } // Library grid data [P1-08]. Read-only, no slug/path handling needed (unlike @@ -163,6 +164,19 @@ ipcMain.handle('vellum:get-paper', (_event, slug: unknown): PaperRecord | null = return getPaper(getDb(), requireSlug(slug, 'vellum:get-paper')) ?? null }) +// [L2-04] Trash and Purge operations ---------------------------------------- +ipcMain.handle('vellum:paper-trash', (_event, slug: unknown): PaperRecord | null => { + return trashPaper(getDb(), requireSlug(slug, 'vellum:paper-trash')) ?? null +}) + +ipcMain.handle('vellum:paper-restore', (_event, slug: unknown): PaperRecord | null => { + return restorePaper(getDb(), requireSlug(slug, 'vellum:paper-restore')) ?? null +}) + +ipcMain.handle('vellum:paper-purge', (_event, slug: unknown): boolean => { + return purgePaper(getDb(), requireSlug(slug, 'vellum:paper-purge'), 'data') +}) + // [P2-01] Notes tab — one freeform markdown note per paper. ----------------- // // `getNote` returns undefined for "no note yet"; normalized to null over IPC diff --git a/electron/preload.ts b/electron/preload.ts index 48c86bb..5378d25 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -36,6 +36,10 @@ const api = { listPapers: (options?: ListPapersOptions): Promise => ipcRenderer.invoke('vellum:list-papers', options ?? {}), getPaper: (slug: string): Promise => ipcRenderer.invoke('vellum:get-paper', slug), + // [L2-04] Trash & Purge + paperTrash: (slug: string): Promise => ipcRenderer.invoke('vellum:paper-trash', slug), + paperRestore: (slug: string): Promise => ipcRenderer.invoke('vellum:paper-restore', slug), + paperPurge: (slug: string): Promise => ipcRenderer.invoke('vellum:paper-purge', slug), // [P2-01] Notes tab — one freeform markdown note per paper. `notesGet` // resolves null when the paper has no note yet (empty-editor state). // `notesSave` is autosave's persistence half — upsert-by-paper-slug, so the diff --git a/package.json b/package.json index 33a9db9..223c7a5 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:gate:collections": "node test/collections-live.mjs", "test:gate:chats": "node test/chats-live.mjs", "test:gate:questions": "node test/questions-live.mjs", + "test:gate:trash": "node test/trash-live.mjs", "dist": "electron-vite build && electron-builder" }, "dependencies": { diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index 969b258..b0a2318 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -46,6 +46,8 @@ beforeEach(() => { highlightsList: vi.fn().mockResolvedValue([]), highlightsCreate: vi.fn(), highlightsDelete: vi.fn().mockResolvedValue(undefined), + paperRestore: vi.fn().mockResolvedValue(null), + paperPurge: vi.fn().mockResolvedValue(true), }, }) }) @@ -122,4 +124,14 @@ describe('App shell', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(await screen.findByRole('tab', { name: 'Attention Is All You Need' })).toBeInTheDocument() }) + it('switches to Trash view when Trash footer item is clicked [L2-04]', async () => { + const user = userEvent.setup() + render() + + const trashBtn = screen.getByRole('button', { name: 'Trash' }) + await user.click(trashBtn) + + expect(await screen.findByRole('region', { name: 'Trash view' })).toBeInTheDocument() + expect(screen.getByText('Attention Is All You Need')).toBeInTheDocument() + }) }) diff --git a/src/app/App.tsx b/src/app/App.tsx index a851975..15d352c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,3 +1,4 @@ +import { TrashView } from './TrashView.js' import type { ChatSessionSummary } from '../../core/chat/repo' import { useEffect, useState } from 'react' import { IngestModal } from './IngestModal' @@ -38,7 +39,7 @@ export function App(): JSX.Element { const [pong, setPong] = useState('…') const [tabs, setTabs] = useState([]) const [activeTabId, setActiveTabId] = useState(null) - const [view, setView] = useState<'reader' | 'library'>('reader') + const [view, setView] = useState<'reader' | 'library' | 'trash'>('reader') const [isIngestOpen, setIsIngestOpen] = useState(false) const [highlightActive, setHighlightActive] = useState(false) const [highlightColor, setHighlightColor] = useState('yellow') @@ -135,6 +136,7 @@ export function App(): JSX.Element { onSelectCollection={handleSelectCollection} selectedSessionId={selectedChatSessionId} onSelectSession={handleSelectChatSession} + onSelectTrash={() => setView('trash')} />
{chatBanner && ( @@ -143,7 +145,11 @@ export function App(): JSX.Element { )} - {view === 'library' ? ( + {view === 'trash' ? ( + setView('library')} + /> + ) : view === 'library' ? ( { + setTabs((current) => current.filter((tab) => tab.id !== slug)) + if (activeTabId === slug) { + const remaining = tabs.filter((tab) => tab.id !== slug) + setActiveTabId(remaining.length > 0 ? remaining[remaining.length - 1].id : null) + } + }} /> ) : ( <> diff --git a/src/app/Library.module.css b/src/app/Library.module.css index fb23a1a..230a516 100644 --- a/src/app/Library.module.css +++ b/src/app/Library.module.css @@ -118,3 +118,50 @@ border-radius: 4px; padding: 2px 6px; } + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + width: 100%; +} + +.trashCardButton { + background: transparent; + border: none; + font-size: 13px; + opacity: 0.4; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + transition: opacity 0.15s, background-color 0.15s; + flex-shrink: 0; +} + +.card:hover .trashCardButton, +.trashCardButton:focus { + opacity: 1; +} + +.trashCardButton:hover { + background: rgba(239, 68, 68, 0.2); +} + +.cardTitleButton { + background: transparent; + border: none; + text-align: left; + padding: 0; + cursor: pointer; + color: inherit; + font: inherit; + flex: 1; +} + +.cardBody { + cursor: pointer; + display: flex; + flex-direction: column; + gap: 8px; +} diff --git a/src/app/Library.test.tsx b/src/app/Library.test.tsx index f037768..3a4e5fa 100644 --- a/src/app/Library.test.tsx +++ b/src/app/Library.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Library } from './Library' @@ -143,4 +143,25 @@ describe('Library', () => { clearBtn.click() expect(onClear).toHaveBeenCalled() }) + it('clicking trash button on a paper card invokes paperTrash and removes it [L2-04]', async () => { + listPapers.mockResolvedValue([makePaper()]) + const paperTrash = vi.fn().mockResolvedValue(null) + const collectionsForPaper = vi.fn().mockResolvedValue([]) + Object.defineProperty(window, 'vellum', { + configurable: true, + value: { listPapers, collectionsForPaper, paperTrash }, + }) + + const onTrash = vi.fn() + render() + + const trashBtn = await screen.findByRole('button', { name: 'Move Attention Is All You Need to trash' }) + fireEvent.click(trashBtn) + + await waitFor(() => { + expect(paperTrash).toHaveBeenCalledWith('arxiv-1706.03762') + expect(onTrash).toHaveBeenCalledWith('arxiv-1706.03762') + expect(screen.queryByText('Attention Is All You Need')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/app/Library.tsx b/src/app/Library.tsx index 3046d51..7b7a8e1 100644 --- a/src/app/Library.tsx +++ b/src/app/Library.tsx @@ -17,6 +17,8 @@ export interface LibraryProps { selectedCollectionId?: number | null selectedCollectionName?: string | null onClearCollectionFilter?: () => void + /** [L2-04] Callback when a paper is moved to Trash. */ + onTrashPaper?: (slug: string) => void } const SORT_OPTIONS: { value: PaperSortColumn; label: string }[] = [ @@ -32,12 +34,25 @@ export function Library({ selectedCollectionId = null, selectedCollectionName = null, onClearCollectionFilter, + onTrashPaper, }: LibraryProps): JSX.Element { const [papers, setPapers] = useState([]) const [paperCollections, setPaperCollections] = useState>({}) const [status, setStatus] = useState('loading') const [search, setSearch] = useState('') const [sort, setSort] = useState('addedAt') + const handleTrashPaper = async (e: React.MouseEvent, slug: string): Promise => { + e.stopPropagation() + try { + if (window.vellum?.paperTrash) { + await window.vellum.paperTrash(slug) + } + setPapers((prev) => prev.filter((p) => p.slug !== slug)) + onTrashPaper?.(slug) + } catch (err) { + console.error('Failed to move paper to trash:', err) + } + } useEffect(() => { let cancelled = false @@ -126,28 +141,48 @@ export function Library({ ) : (
{papers.map((paper) => ( - +
+ + +
+
onOpenPaper({ slug: paper.slug, title: paper.title })} + > + + {formatAuthors(paper.authors)} + {paper.year ? ` · ${paper.year}` : ''} + + {paperCollections[paper.slug] && paperCollections[paper.slug].length > 0 && ( +
+ {paperCollections[paper.slug].map((cName) => ( + + {cName} + + ))} +
+ )} +
+
))} )} diff --git a/src/app/Sidebar.test.tsx b/src/app/Sidebar.test.tsx index d2d82b2..91e6cc9 100644 --- a/src/app/Sidebar.test.tsx +++ b/src/app/Sidebar.test.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Sidebar } from './Sidebar' afterEach(() => { @@ -60,4 +60,13 @@ describe('Sidebar', () => { await user.click(screen.getByRole('button', { name: 'Library' })) expect(screen.getByRole('button', { name: 'Library' })).toHaveAttribute('aria-current', 'page') }) + it('invokes onSelectTrash when Trash is clicked and handler is provided [L2-04]', async () => { + const user = userEvent.setup() + const onSelectTrash = vi.fn() + render() + + await user.click(screen.getByRole('button', { name: 'Trash' })) + + expect(onSelectTrash).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/app/Sidebar.tsx b/src/app/Sidebar.tsx index 7117998..f8e1b81 100644 --- a/src/app/Sidebar.tsx +++ b/src/app/Sidebar.tsx @@ -34,6 +34,8 @@ interface SidebarProps { onSelectCollection?: (id: number | null, name: string | null) => void selectedSessionId?: number | null onSelectSession?: (session: ChatSessionSummary) => void + /** [L2-04] Callback when Trash is selected */ + onSelectTrash?: () => void } export function Sidebar({ @@ -42,6 +44,7 @@ export function Sidebar({ onSelectCollection, selectedSessionId = null, onSelectSession, + onSelectTrash, }: SidebarProps = {}): JSX.Element { const [active, setActive] = useState('Home') const [libraryView, setLibraryView] = useState('Files') @@ -117,7 +120,14 @@ export function Sidebar({ type="button" className={item === footerView ? `${styles.footerItem} ${styles.footerItemActive}` : styles.footerItem} aria-pressed={item === footerView} - onClick={() => setFooterView((current) => (current === item ? null : item))} + onClick={() => { + if (item === 'Trash' && onSelectTrash) { + setFooterView(null) + onSelectTrash() + } else { + setFooterView((current) => (current === item ? null : item)) + } + }} > {item} diff --git a/src/app/TrashView.module.css b/src/app/TrashView.module.css new file mode 100644 index 0000000..e967cdf --- /dev/null +++ b/src/app/TrashView.module.css @@ -0,0 +1,235 @@ +.container { + display: flex; + flex-direction: column; + height: 100%; + padding: 24px 32px; + background-color: var(--color-bg, #0f1117); + color: var(--color-text, #f1f5f9); + overflow-y: auto; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + border-bottom: 1px solid var(--color-border, #1e293b); + padding-bottom: 16px; +} + +.titleGroup { + display: flex; + align-items: baseline; + gap: 12px; +} + +.title { + margin: 0; + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.count { + font-size: 13px; + color: var(--color-text-secondary, #94a3b8); +} + +.backButton { + background: transparent; + border: 1px solid var(--color-border, #334155); + color: var(--color-text-secondary, #94a3b8); + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + transition: all 0.15s ease; +} + +.backButton:hover { + background-color: var(--color-surface, #1e293b); + color: var(--color-text, #f1f5f9); +} + +.list { + display: flex; + flex-direction: column; + gap: 12px; + list-style: none; + padding: 0; + margin: 0; +} + +.item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background-color: var(--color-surface, #161b26); + border: 1px solid var(--color-border, #1e293b); + border-radius: 8px; + gap: 16px; +} + +.itemContent { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; +} + +.paperTitle { + font-size: 15px; + font-weight: 500; + color: var(--color-text, #f1f5f9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.paperMeta { + font-size: 12px; + color: var(--color-text-secondary, #94a3b8); +} + +.itemActions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.restoreButton { + padding: 6px 14px; + background-color: transparent; + border: 1px solid #3b82f6; + color: #60a5fa; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.restoreButton:hover { + background-color: rgba(59, 130, 246, 0.12); +} + +.purgeButton { + padding: 6px 14px; + background-color: transparent; + border: 1px solid #ef4444; + color: #f87171; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.purgeButton:hover { + background-color: rgba(239, 68, 68, 0.12); +} + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 16px; + color: var(--color-text-secondary, #94a3b8); + font-size: 14px; + text-align: center; + gap: 8px; +} + +.emptyIcon { + font-size: 32px; + opacity: 0.6; +} + +.modalOverlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(2px); +} + +.modal { + background-color: var(--color-surface, #181d27); + border: 1px solid var(--color-border, #2d3748); + border-radius: 12px; + padding: 24px; + max-width: 440px; + width: 90%; + display: flex; + flex-direction: column; + gap: 16px; + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.5); +} + +.modalTitle { + margin: 0; + font-size: 17px; + font-weight: 600; + color: #f87171; +} + +.modalMessage { + margin: 0; + font-size: 14px; + line-height: 1.5; + color: var(--color-text, #cbd5e1); +} + +.modalActions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 8px; +} + +.cancelButton { + padding: 8px 16px; + background: transparent; + border: 1px solid var(--color-border, #334155); + color: var(--color-text-secondary, #94a3b8); + border-radius: 6px; + font-size: 13px; + cursor: pointer; +} + +.cancelButton:hover { + background-color: var(--color-surface, #1e293b); + color: var(--color-text, #f1f5f9); +} + +.confirmDeleteButton { + padding: 8px 16px; + background-color: #dc2626; + border: none; + color: white; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} + +.confirmDeleteButton:hover { + background-color: #b91c1c; +} + +.errorBanner { + background-color: rgba(239, 68, 68, 0.15); + border: 1px solid #ef4444; + color: #fca5a5; + padding: 10px 14px; + border-radius: 6px; + font-size: 13px; + margin-bottom: 16px; +} diff --git a/src/app/TrashView.test.tsx b/src/app/TrashView.test.tsx new file mode 100644 index 0000000..9771733 --- /dev/null +++ b/src/app/TrashView.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TrashView } from './TrashView.js' +import type { PaperRecord } from '../../core/library/repo.js' + +describe('TrashView [L2-04]', () => { + let listPapers: ReturnType + let paperRestore: ReturnType + let paperPurge: ReturnType + + const mockPapers: PaperRecord[] = [ + { + slug: 'trashed-1', + title: 'Attention Is All You Need', + authors: ['Vaswani', 'Shazeer'], + addedAt: '2026-01-01', + trashedAt: '2026-02-01T12:00:00.000Z', + }, + { + slug: 'trashed-2', + title: 'BERT: Pre-training of Deep Bidirectional Transformers', + authors: ['Devlin'], + addedAt: '2026-01-02', + trashedAt: '2026-02-02T12:00:00.000Z', + }, + ] + + beforeEach(() => { + listPapers = vi.fn().mockResolvedValue(mockPapers) + paperRestore = vi.fn().mockResolvedValue({ ...mockPapers[0], trashedAt: null }) + paperPurge = vi.fn().mockResolvedValue(true) + + Object.defineProperty(window, 'vellum', { + configurable: true, + value: { listPapers, paperRestore, paperPurge }, + }) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('renders loading state, then loads and lists trashed papers', async () => { + render() + expect(screen.getByText(/loading trash/i)).toBeInTheDocument() + + await waitFor(() => { + expect(screen.getByText('Attention Is All You Need')).toBeInTheDocument() + expect(screen.getByText(/BERT: Pre-training/)).toBeInTheDocument() + }) + + expect(listPapers).toHaveBeenCalledWith({ trashed: true }) + }) + + it('renders empty state when there are no trashed papers', async () => { + listPapers.mockResolvedValueOnce([]) + render() + + await waitFor(() => { + expect(screen.getByText(/trash is empty/i)).toBeInTheDocument() + }) + }) + + it('restores a paper when Restore button is clicked', async () => { + const onRestored = vi.fn() + render() + + const restoreBtn = await screen.findByRole('button', { name: 'Restore Attention Is All You Need' }) + fireEvent.click(restoreBtn) + + await waitFor(() => { + expect(paperRestore).toHaveBeenCalledWith('trashed-1') + expect(onRestored).toHaveBeenCalledWith('trashed-1') + expect(screen.queryByText('Attention Is All You Need')).not.toBeInTheDocument() + }) + }) + + it('opens confirmation modal and cancels purge when Cancel is clicked', async () => { + render() + + const purgeBtn = await screen.findByRole('button', { name: 'Delete Attention Is All You Need permanently' }) + fireEvent.click(purgeBtn) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText(/This action cannot be undone/i)).toBeInTheDocument() + + const cancelBtn = screen.getByRole('button', { name: 'Cancel purge' }) + fireEvent.click(cancelBtn) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(paperPurge).not.toHaveBeenCalled() + }) + + it('permanently purges paper when confirmed in modal', async () => { + render() + + const purgeBtn = await screen.findByRole('button', { name: 'Delete Attention Is All You Need permanently' }) + fireEvent.click(purgeBtn) + + const confirmBtn = screen.getByRole('button', { name: 'Confirm purge' }) + fireEvent.click(confirmBtn) + + await waitFor(() => { + expect(paperPurge).toHaveBeenCalledWith('trashed-1') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByText('Attention Is All You Need')).not.toBeInTheDocument() + }) + }) + + it('displays actionable error banner if restore or purge fails', async () => { + paperRestore.mockRejectedValueOnce(new Error('Database write failure')) + render() + + const restoreBtn = await screen.findByRole('button', { name: 'Restore Attention Is All You Need' }) + fireEvent.click(restoreBtn) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Database write failure') + }) + }) +}) diff --git a/src/app/TrashView.tsx b/src/app/TrashView.tsx new file mode 100644 index 0000000..bb41d68 --- /dev/null +++ b/src/app/TrashView.tsx @@ -0,0 +1,175 @@ +// [L2-04] TrashView: lists soft-deleted papers with restore and permanent purge actions. +import { useState, useEffect } from 'react' +import type { PaperRecord } from '../../core/library/repo.js' +import styles from './TrashView.module.css' + +export interface TrashViewProps { + onBackToLibrary?: () => void + onPaperRestored?: (slug: string) => void +} + +export function TrashView({ onBackToLibrary, onPaperRestored }: TrashViewProps): JSX.Element { + const [trashedPapers, setTrashedPapers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [confirmPurgeTarget, setConfirmPurgeTarget] = useState(null) + const [purging, setPurging] = useState(false) + + const loadTrash = async (): Promise => { + try { + setLoading(true) + setError(null) + const papers = await window.vellum.listPapers({ trashed: true }) + setTrashedPapers(papers) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load trashed papers') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void loadTrash() + }, []) + + const handleRestore = async (paper: PaperRecord): Promise => { + try { + setError(null) + await window.vellum.paperRestore(paper.slug) + setTrashedPapers((prev) => prev.filter((p) => p.slug !== paper.slug)) + onPaperRestored?.(paper.slug) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to restore paper') + } + } + + const handlePurge = async (): Promise => { + if (!confirmPurgeTarget) return + try { + setPurging(true) + setError(null) + await window.vellum.paperPurge(confirmPurgeTarget.slug) + setTrashedPapers((prev) => prev.filter((p) => p.slug !== confirmPurgeTarget.slug)) + setConfirmPurgeTarget(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to permanently delete paper') + } finally { + setPurging(false) + } + } + + const formatAuthors = (authors: string[]): string => { + if (!authors || authors.length === 0) return 'Unknown authors' + if (authors.length <= 2) return authors.join(' & ') + return `${authors[0]} et al.` + } + + return ( +
+
+
+

Trash

+ + {trashedPapers.length} {trashedPapers.length === 1 ? 'item' : 'items'} + +
+ {onBackToLibrary && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Loading trash…
+ ) : trashedPapers.length === 0 ? ( +
+
🗑️
+
Trash is empty
+
+ ) : ( +
    + {trashedPapers.map((paper) => ( +
  • +
    +
    + {paper.title} +
    +
    + {formatAuthors(paper.authors)} + {paper.year ? ` · ${paper.year}` : ''} + {paper.trashedAt + ? ` · Trashed ${new Date(paper.trashedAt).toLocaleDateString()}` + : ''} +
    +
    +
    + + +
    +
  • + ))} +
+ )} + + {confirmPurgeTarget && ( +
+
+

+ Permanently delete paper? +

+

+ Are you sure you want to permanently delete “{confirmPurgeTarget.title}”? + This will remove all associated notes, highlights, chat sessions, and downloaded files from your device. + This action cannot be undone. +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/test/trash-live.mjs b/test/trash-live.mjs new file mode 100644 index 0000000..3dfa4f6 --- /dev/null +++ b/test/trash-live.mjs @@ -0,0 +1,165 @@ +// Live Playwright test for [L2-04] Recoverable Trash and Permanent Purge +import { _electron as electron } from 'playwright' +import path from 'path' +import { fileURLToPath } from 'url' +import fs from 'node:fs' +import assert from 'node:assert' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(__dirname, '..') + +async function runLiveVerification() { + console.log('=== [L2-04] Live Trash & Purge Verification Starting ===') + + // Step 1: Launch Electron Instance 1 + console.log('1. Launching Electron App...') + let app = await electron.launch({ + args: ['--no-sandbox', path.join(ROOT, 'out/main/index.cjs')], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '' }, + }) + + let window = await app.firstWindow() + await window.waitForLoadState('domcontentloaded') + await window.waitForTimeout(1000) + + // Step 2: Ingest two real papers + console.log('2. Ingesting test papers...') + const pdf1 = path.join(ROOT, 'core/ingest/fixtures/sample.pdf') + const pdf2 = path.join(ROOT, 'core/ingest/fixtures/sample2.pdf') + + const p1 = await window.evaluate((p) => window.vellum.ingest(p), pdf1) + const p2 = await window.evaluate((p) => window.vellum.ingest(p), pdf2) + console.log(`Ingested: "${p1.title}" (${p1.slug}) and "${p2.title}" (${p2.slug})`) + + // Step 3: Switch to Library view and verify both papers + console.log('3. Navigating to Library...') + const libNavBtn = await window.waitForSelector('button:has-text("Library")') + await libNavBtn.click() + await window.waitForTimeout(500) + + await window.getByText(p1.title, { exact: true }).waitFor() + await window.getByText(p2.title, { exact: true }).waitFor() + console.log('✓ Both papers visible in Library') + + // Step 4: Move paper 1 to trash + console.log('4. Moving target paper to Trash...') + const trashBtn = await window.waitForSelector(`button[aria-label="Move ${p1.title} to trash"]`) + await trashBtn.click() + await window.waitForTimeout(500) + + const p1InLib = await window.getByText(p1.title, { exact: true }).count() + assert.strictEqual(p1InLib, 0, 'Paper 1 should be hidden from Library') + await window.getByText(p2.title, { exact: true }).waitFor() + console.log('✓ Paper 1 hidden from Library; Paper 2 remains') + + // Step 5: Open Trash view and verify paper 1 is listed + console.log('5. Verifying Trash view...') + const trashNavBtn = await window.waitForSelector('button:has-text("Trash")') + await trashNavBtn.click() + await window.waitForTimeout(500) + + await window.waitForSelector('div[aria-label="Trash view"]') + await window.getByText(p1.title, { exact: true }).waitFor() + console.log('✓ Paper 1 present in Trash view') + + // Step 6: Restart Electron to verify persistence of trashed state + console.log('6. Restarting Electron to test persistence across restarts...') + await app.close() + + app = await electron.launch({ + args: ['--no-sandbox', path.join(ROOT, 'out/main/index.cjs')], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '' }, + }) + window = await app.firstWindow() + await window.waitForLoadState('domcontentloaded') + await window.waitForTimeout(1000) + + // Verify Paper 1 still hidden in Library after restart + const libNavBtn2 = await window.waitForSelector('button:has-text("Library")') + await libNavBtn2.click() + await window.waitForTimeout(500) + + assert.strictEqual(await window.getByText(p1.title, { exact: true }).count(), 0, 'Paper 1 still hidden from Library after restart') + await window.getByText(p2.title, { exact: true }).waitFor() + console.log('✓ Trashed state persisted across restart (Library still excludes it)') + + // Verify Paper 1 still present in Trash view after restart + const trashNavBtn2 = await window.waitForSelector('button:has-text("Trash")') + await trashNavBtn2.click() + await window.waitForTimeout(500) + + await window.getByText(p1.title, { exact: true }).waitFor() + console.log('✓ Paper 1 still present in Trash view after restart') + + // Step 7: Test Cancel Purge + console.log('7. Testing purge cancellation...') + const purgeBtn = await window.waitForSelector(`button[aria-label="Delete ${p1.title} permanently"]`) + await purgeBtn.click() + await window.waitForTimeout(300) + + await window.waitForSelector('role=dialog') + const cancelBtn = await window.waitForSelector('button[aria-label="Cancel purge"]') + await cancelBtn.click() + await window.waitForTimeout(300) + + assert.strictEqual(await window.$('role=dialog'), null, 'Purge modal should close') + await window.getByText(p1.title, { exact: true }).waitFor() + console.log('✓ Cancel purge kept paper in Trash without deleting') + + // Step 8: Test Restore + console.log('8. Testing Restore...') + const restoreBtn = await window.waitForSelector(`button[aria-label="Restore ${p1.title}"]`) + await restoreBtn.click() + await window.waitForTimeout(500) + + // Navigate back to Library and verify Paper 1 is back + const backBtn = await window.waitForSelector('button:has-text("Back to Library")') + await backBtn.click() + await window.waitForTimeout(500) + + await window.getByText(p1.title, { exact: true }).waitFor() + await window.getByText(p2.title, { exact: true }).waitFor() + console.log('✓ Paper 1 successfully restored to Library') + + // Step 9: Re-trash and confirm permanent purge + console.log('9. Re-trashing and confirming permanent purge...') + const trashBtn2 = await window.waitForSelector(`button[aria-label="Move ${p1.title} to trash"]`) + await trashBtn2.click() + await window.waitForTimeout(500) + + const trashNavBtn3 = await window.waitForSelector('button:has-text("Trash")') + await trashNavBtn3.click() + await window.waitForTimeout(500) + + const purgeBtn2 = await window.waitForSelector(`button[aria-label="Delete ${p1.title} permanently"]`) + await purgeBtn2.click() + await window.waitForTimeout(300) + + const confirmPurgeBtn = await window.waitForSelector('button[aria-label="Confirm purge"]') + await confirmPurgeBtn.click() + await window.waitForTimeout(600) + + // Paper 1 should no longer be in Trash + assert.strictEqual(await window.getByText(p1.title, { exact: true }).count(), 0, 'Paper 1 purged from Trash view') + console.log('✓ Paper 1 purged from Trash view') + + // Verify on disk that paper directory is gone + const paper1Dir = path.join(ROOT, 'data/papers', p1.slug) + assert.strictEqual(fs.existsSync(paper1Dir), false, 'Paper 1 directory must be deleted from disk') + console.log('✓ Paper 1 files completely purged from disk') + + // Verify paper 2 remains untouched in DB and on disk + const paper2Dir = path.join(ROOT, 'data/papers', p2.slug) + assert.strictEqual(fs.existsSync(paper2Dir), true, 'Paper 2 directory must still exist') + const paper2InDb = await window.evaluate((slug) => window.vellum.getPaper(slug), p2.slug) + assert.ok(paper2InDb, 'Paper 2 must still exist in DB') + console.log('✓ Paper 2 untouched and verified') + + await app.close() + console.log('\n🎉 ALL LIVE TRASH & PURGE CHECKS PASSED!') +} + +runLiveVerification().catch((err) => { + console.error('❌ LIVE TEST FAILED:', err) + process.exit(1) +}) From 6407e8b176d9a49923163c0a827d4be88c28c2b9 Mon Sep 17 00:00:00 2001 From: Kritarth-Dandapat Date: Sat, 12 Sep 2026 11:46:31 -0400 Subject: [PATCH 2/2] chore: trigger fresh gate check