The previous commit (f963ae3) used per_item_max_bytes and per_vault_*_max_bytes
which don't match the Rust core's struct (per_item_max_count and
per_vault_*_cap_bytes). Also fixes the per-item semantics: it's a COUNT of
attachments per item, not a byte sum.
Spec and plan docs updated in-place so future Task 7 cap-enforcement
implementation uses the correct names + semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2119 lines
83 KiB
Markdown
2119 lines
83 KiB
Markdown
# Plan 1C-γ₁ Implementation Plan — Attachments + Document type
|
||
|
||
> **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:** Wire the Rust attachment-encrypt surface into the extension, add `GitHost.putBlob` with Git Data API fallback for >900 KB blobs, ship the Document item type, and surface attachments in the compact disclosure pattern across all type forms.
|
||
|
||
**Architecture:** Layered bottom-up so each task ships a working increment. Types tighten first (1), then transport (2-4), then service-worker logic (5-6), then popup UI (7-10). The disclosure component is shared between all type forms; Document type adds the signature-block treatment for its required `primary_attachment`.
|
||
|
||
**Tech Stack:** TypeScript, vitest + happy-dom (popup tests), webpack, Rust core via WASM (`attachment_encrypt` / `attachment_decrypt` already exposed). No Rust changes — the core is complete.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-24-relicario-extension-1c-gamma1-design.md` (commit `6f5ef43`).
|
||
|
||
---
|
||
|
||
## File overview
|
||
|
||
**Shared types & messages:**
|
||
- `extension/src/shared/types.ts` — tighten `attachment_caps: unknown` → typed `AttachmentCaps` (Task 1)
|
||
- `extension/src/shared/messages.ts` — add `upload_attachment`, `download_attachment` message types (Task 6)
|
||
|
||
**Service worker:**
|
||
- `extension/src/service-worker/git-host.ts` — add `putBlob`, `getBlob`, `deleteBlob` to interface; export `BLOB_THRESHOLD_BYTES` constant (Task 2)
|
||
- `extension/src/service-worker/github.ts` — implement the three new methods (Task 3)
|
||
- `extension/src/service-worker/gitea.ts` — implement the three new methods (Task 4)
|
||
- `extension/src/service-worker/vault.ts` — add `addAttachmentToItem`, `removeAttachmentsFromItem` helpers (Task 5)
|
||
- `extension/src/service-worker/router/popup-only.ts` — add 2 message handlers (Task 6)
|
||
|
||
**Popup:**
|
||
- `extension/src/popup/components/attachments-disclosure.ts` — NEW shared component (Task 7)
|
||
- `extension/src/popup/components/types/document.ts` — NEW Document type form + detail (Task 9)
|
||
- `extension/src/popup/components/item-form.ts` — route Document case (Task 10)
|
||
- `extension/src/popup/components/item-list.ts` — add 📎 indicator (Task 8)
|
||
- `extension/src/popup/components/types/{login,secure-note,identity,card,key,totp}.ts` — wire disclosure (Task 8)
|
||
- `extension/src/popup/styles.css` — attachment-row + signature-block rules (Tasks 7, 9)
|
||
|
||
**Tests:**
|
||
- `extension/src/service-worker/__tests__/git-host.test.ts` — NEW (putBlob threshold + Git Data API sequence — Task 3 introduces, Task 4 extends)
|
||
- `extension/src/popup/components/__tests__/attachments-disclosure.test.ts` — NEW (Task 7)
|
||
- `extension/src/popup/components/types/__tests__/document.save.test.ts` — NEW (Task 9)
|
||
- `extension/src/service-worker/router/__tests__/router.test.ts` — extended with 4 router cases (Task 6)
|
||
|
||
Working dir: `/home/alee/Sources/relicario`. Branch: main. Direct-to-main per project convention. Do NOT push.
|
||
|
||
---
|
||
|
||
## Task 1: Tighten `attachment_caps` type
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/shared/types.ts`
|
||
|
||
- [ ] **Step 1: Edit `extension/src/shared/types.ts`** — find the `VaultSettings` interface (around line 199–204) and add an `AttachmentCaps` type definition above it. Then change the field's type.
|
||
|
||
Find:
|
||
```ts
|
||
export interface VaultSettings {
|
||
trash_retention: TrashRetention;
|
||
field_history_retention: HistoryRetention;
|
||
generator_defaults: GeneratorRequest;
|
||
attachment_caps: unknown; // opaque — γ tightens
|
||
autofill_origin_acks: { [hostname: string]: number };
|
||
}
|
||
```
|
||
|
||
Replace the comment line and add the type definition just above the interface:
|
||
|
||
```ts
|
||
/// Optional per-level caps on attachment plaintext sizes.
|
||
/// All fields optional; if undefined that level is uncapped.
|
||
/// Field names mirror the Rust core's `AttachmentCaps` struct.
|
||
/// γ₁ enforces; γ₂ ships the configuration UI.
|
||
export interface AttachmentCaps {
|
||
per_attachment_max_bytes?: number;
|
||
per_item_max_count?: number; // count of attachments per item, NOT bytes
|
||
per_vault_soft_cap_bytes?: number; // soft cap — warn user but allow
|
||
per_vault_hard_cap_bytes?: number; // hard cap — reject
|
||
}
|
||
|
||
export interface VaultSettings {
|
||
trash_retention: TrashRetention;
|
||
field_history_retention: HistoryRetention;
|
||
generator_defaults: GeneratorRequest;
|
||
attachment_caps: AttachmentCaps;
|
||
autofill_origin_acks: { [hostname: string]: number };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify type-check** — `cd extension && bunx tsc --noEmit 2>&1 | tail -3`. Expected: zero errors. (Existing code that initialized `attachment_caps: {}` will still satisfy `AttachmentCaps` because all fields are optional.)
|
||
|
||
- [ ] **Step 3: Run vitest** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 128 passed.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/shared/types.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/shared): tighten VaultSettings.attachment_caps to AttachmentCaps
|
||
|
||
All four cap fields optional; undefined means uncapped. γ₁ enforces;
|
||
γ₂ adds the configuration UI.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Extend `GitHost` interface
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/service-worker/git-host.ts`
|
||
|
||
- [ ] **Step 1: Edit `extension/src/service-worker/git-host.ts`** — find the `GitHost` interface (around line 7) and add three methods after `listDir`:
|
||
|
||
```ts
|
||
export interface GitHost {
|
||
/// Read a single file from the repo, returning its raw bytes.
|
||
readFile(path: string): Promise<Uint8Array>;
|
||
|
||
/// Create or update a file in the repo with a commit message.
|
||
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
|
||
|
||
/// Delete a file from the repo with a commit message.
|
||
deleteFile(path: string, message: string): Promise<void>;
|
||
|
||
/// List file names in a directory (non-recursive).
|
||
listDir(path: string): Promise<string[]>;
|
||
|
||
/// Write an opaque binary blob to the repo. Optimized for large
|
||
/// attachments — implementations switch from Contents API to Git
|
||
/// Data API when content exceeds BLOB_THRESHOLD_BYTES.
|
||
/// Returns the path that was written (same as input, for chaining).
|
||
putBlob(path: string, content: Uint8Array, message: string): Promise<string>;
|
||
|
||
/// Read an opaque binary blob from the repo. Same semantics as
|
||
/// readFile for current sizes. Distinct method so we can later add
|
||
/// streaming/chunked reads for very large blobs.
|
||
getBlob(path: string): Promise<Uint8Array>;
|
||
|
||
/// Delete a blob from the repo. Currently identical to deleteFile;
|
||
/// kept distinct for symmetry with putBlob.
|
||
deleteBlob(path: string, message: string): Promise<void>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add the threshold constant** — append after the interface:
|
||
|
||
```ts
|
||
/// Pre-base64 byte size at which putBlob switches from Contents API to
|
||
/// Git Data API. 900 KB leaves headroom for base64 inflation (1.33×) and
|
||
/// JSON wrapping under both GitHub's and Gitea's Contents API soft-limits.
|
||
export const BLOB_THRESHOLD_BYTES = 900 * 1024;
|
||
```
|
||
|
||
- [ ] **Step 3: Verify type-check** — `cd extension && bunx tsc --noEmit 2>&1 | tail -10`. **Expected: TypeScript will REPORT ERRORS** because `GitHubHost` and `GiteaHost` no longer satisfy the extended interface (they don't implement the three new methods yet). This is expected — Tasks 3 and 4 add the implementations.
|
||
|
||
To allow the project to keep compiling between Task 2 and Task 3, add a temporary stub to each impl. Edit `extension/src/service-worker/github.ts` — append these stubs at the END of the `GitHubHost` class body, just before the closing `}`:
|
||
|
||
```ts
|
||
async putBlob(path: string, _content: Uint8Array, _message: string): Promise<string> {
|
||
throw new Error(`GitHubHost.putBlob not implemented — Task 3`);
|
||
}
|
||
|
||
async getBlob(path: string): Promise<Uint8Array> {
|
||
return this.readFile(path);
|
||
}
|
||
|
||
async deleteBlob(path: string, message: string): Promise<void> {
|
||
return this.deleteFile(path, message);
|
||
}
|
||
```
|
||
|
||
Same stub for `extension/src/service-worker/gitea.ts` — append before the closing `}` of the `GiteaHost` class:
|
||
|
||
```ts
|
||
async putBlob(path: string, _content: Uint8Array, _message: string): Promise<string> {
|
||
throw new Error(`GiteaHost.putBlob not implemented — Task 4`);
|
||
}
|
||
|
||
async getBlob(path: string): Promise<Uint8Array> {
|
||
return this.readFile(path);
|
||
}
|
||
|
||
async deleteBlob(path: string, message: string): Promise<void> {
|
||
return this.deleteFile(path, message);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Re-verify** — `cd extension && bunx tsc --noEmit 2>&1 | tail -3`. Expected: zero errors.
|
||
|
||
- [ ] **Step 5: Run vitest** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 128 passed.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/service-worker/git-host.ts \
|
||
extension/src/service-worker/github.ts \
|
||
extension/src/service-worker/gitea.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/sw): extend GitHost interface with putBlob/getBlob/deleteBlob
|
||
|
||
Adds the three blob ops to the interface and a BLOB_THRESHOLD_BYTES
|
||
constant. Both GitHubHost and GiteaHost ship temporary stubs so the
|
||
build stays green until tasks 3-4 fill in real implementations.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: GitHubHost — putBlob with Git Data API fallback (+ tests)
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/service-worker/github.ts` — replace stubs with real implementations
|
||
- Create: `extension/src/service-worker/__tests__/git-host.test.ts` — NEW test file
|
||
|
||
- [ ] **Step 1: Create the test file** at `extension/src/service-worker/__tests__/git-host.test.ts` with this content:
|
||
|
||
```ts
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { GitHubHost } from '../github';
|
||
import { BLOB_THRESHOLD_BYTES } from '../git-host';
|
||
|
||
const REPO = 'alee/test-vault';
|
||
const TOKEN = 'ghp_TEST';
|
||
|
||
function setupFetch(): ReturnType<typeof vi.fn> {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
return fetchMock;
|
||
}
|
||
|
||
describe('GitHubHost.putBlob', () => {
|
||
beforeEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('uses Contents API when content is at threshold', async () => {
|
||
const fetchMock = setupFetch();
|
||
// First fetch: GET existing file → 404 (create path)
|
||
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' } as Response);
|
||
// Second fetch: PUT contents → 201 created
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({}) } as Response);
|
||
|
||
const host = new GitHubHost(REPO, TOKEN);
|
||
const small = new Uint8Array(BLOB_THRESHOLD_BYTES); // exactly threshold
|
||
await host.putBlob('attachments/abc.bin', small, 'add abc');
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
const lastCall = fetchMock.mock.calls[1];
|
||
expect(lastCall[0]).toContain('/contents/'); // Contents API
|
||
expect(lastCall[1]?.method).toBe('PUT');
|
||
});
|
||
|
||
it('uses Git Data API fallback when content exceeds threshold', async () => {
|
||
const fetchMock = setupFetch();
|
||
// 1. POST /git/blobs → blob SHA
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'blobsha111' }) } as Response);
|
||
// 2. GET /git/refs/heads/main → ref { object: { sha: 'commitsha' } }
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ object: { sha: 'commitsha111' } }) } as Response);
|
||
// 3. GET /git/commits/commitsha111 → commit { tree: { sha: 'treesha' } }
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ tree: { sha: 'treesha111' } }) } as Response);
|
||
// 4. POST /git/trees → new tree { sha: 'newtreesha' }
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newtreesha111' }) } as Response);
|
||
// 5. POST /git/commits → new commit { sha: 'newcommitsha' }
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newcommitsha111' }) } as Response);
|
||
// 6. PATCH /git/refs/heads/main → updated
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) } as Response);
|
||
|
||
const host = new GitHubHost(REPO, TOKEN);
|
||
const big = new Uint8Array(BLOB_THRESHOLD_BYTES + 1); // one byte over
|
||
await host.putBlob('attachments/big.bin', big, 'add big');
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(6);
|
||
expect(fetchMock.mock.calls[0][0]).toContain('/git/blobs');
|
||
expect(fetchMock.mock.calls[1][0]).toContain('/git/refs/heads/');
|
||
expect(fetchMock.mock.calls[3][0]).toContain('/git/trees');
|
||
expect(fetchMock.mock.calls[4][0]).toContain('/git/commits');
|
||
expect(fetchMock.mock.calls[5][1]?.method).toBe('PATCH');
|
||
});
|
||
|
||
it('throws when blob creation fails in fallback path', async () => {
|
||
const fetchMock = setupFetch();
|
||
fetchMock.mockResolvedValueOnce({ ok: false, status: 422, statusText: 'Unprocessable', text: async () => 'too big' } as Response);
|
||
|
||
const host = new GitHubHost(REPO, TOKEN);
|
||
const big = new Uint8Array(BLOB_THRESHOLD_BYTES + 1);
|
||
await expect(host.putBlob('attachments/big.bin', big, 'add big')).rejects.toThrow(/422|too big/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the new tests — they should FAIL** (because the stub throws):
|
||
|
||
```bash
|
||
cd extension && bun run test src/service-worker/__tests__/git-host.test.ts 2>&1 | tail -10
|
||
```
|
||
|
||
Expected: 3 tests FAIL with error matching the stub message.
|
||
|
||
- [ ] **Step 3: Implement real `putBlob`, `getBlob`, `deleteBlob` in `extension/src/service-worker/github.ts`**.
|
||
|
||
Replace the three stub methods (added in Task 2) with these implementations. Add a `private repoPath` and `private apiBase` member if not already present (they may need adjusting from the constructor — see existing constructor). The existing constructor sets `baseUrl = api.github.com/repos/{repoPath}/contents`; we need to also keep the bare `repoPath` for Git Data API URLs.
|
||
|
||
Modify the constructor to store `repoPath` as a class member:
|
||
|
||
```ts
|
||
export class GitHubHost implements GitHost {
|
||
private baseUrl: string; // ...repos/{repoPath}/contents
|
||
private gitApiBase: string; // ...repos/{repoPath}/git
|
||
private branch: string = 'main';
|
||
private headers: Record<string, string>;
|
||
|
||
constructor(repoPath: string, apiToken: string) {
|
||
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
|
||
this.gitApiBase = `https://api.github.com/repos/${repoPath}/git`;
|
||
this.headers = {
|
||
'Authorization': `Bearer ${apiToken}`,
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/vnd.github.v3+json',
|
||
'X-GitHub-Api-Version': '2022-11-28',
|
||
};
|
||
}
|
||
```
|
||
|
||
Then replace the three stub methods with:
|
||
|
||
```ts
|
||
async putBlob(path: string, content: Uint8Array, message: string): Promise<string> {
|
||
if (content.length <= BLOB_THRESHOLD_BYTES) {
|
||
// Contents API path — same as writeFile
|
||
await this.writeFile(path, content, message);
|
||
return path;
|
||
}
|
||
|
||
// Git Data API fallback for large blobs.
|
||
// 1. Create the blob.
|
||
const blobResp = await fetch(`${this.gitApiBase}/blobs`, {
|
||
method: 'POST',
|
||
headers: this.headers,
|
||
body: JSON.stringify({
|
||
content: uint8ArrayToBase64(content),
|
||
encoding: 'base64',
|
||
}),
|
||
});
|
||
if (!blobResp.ok) {
|
||
const text = await blobResp.text();
|
||
throw new Error(`GitHub putBlob create-blob ${path}: ${blobResp.status} ${text}`);
|
||
}
|
||
const { sha: blobSha } = await blobResp.json() as { sha: string };
|
||
|
||
// 2. Get current ref (branch tip commit SHA).
|
||
const refResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, { headers: this.headers });
|
||
if (!refResp.ok) {
|
||
throw new Error(`GitHub putBlob get-ref ${this.branch}: ${refResp.status} ${refResp.statusText}`);
|
||
}
|
||
const { object: { sha: commitSha } } = await refResp.json() as { object: { sha: string } };
|
||
|
||
// 3. Get current commit's tree SHA.
|
||
const commitResp = await fetch(`${this.gitApiBase}/commits/${commitSha}`, { headers: this.headers });
|
||
if (!commitResp.ok) {
|
||
throw new Error(`GitHub putBlob get-commit ${commitSha}: ${commitResp.status} ${commitResp.statusText}`);
|
||
}
|
||
const { tree: { sha: baseTreeSha } } = await commitResp.json() as { tree: { sha: string } };
|
||
|
||
// 4. Create new tree adding the blob at `path` on top of base tree.
|
||
const treeResp = await fetch(`${this.gitApiBase}/trees`, {
|
||
method: 'POST',
|
||
headers: this.headers,
|
||
body: JSON.stringify({
|
||
base_tree: baseTreeSha,
|
||
tree: [{ path, mode: '100644', type: 'blob', sha: blobSha }],
|
||
}),
|
||
});
|
||
if (!treeResp.ok) {
|
||
const text = await treeResp.text();
|
||
throw new Error(`GitHub putBlob create-tree: ${treeResp.status} ${text}`);
|
||
}
|
||
const { sha: newTreeSha } = await treeResp.json() as { sha: string };
|
||
|
||
// 5. Create new commit pointing at the new tree.
|
||
const newCommitResp = await fetch(`${this.gitApiBase}/commits`, {
|
||
method: 'POST',
|
||
headers: this.headers,
|
||
body: JSON.stringify({
|
||
message,
|
||
tree: newTreeSha,
|
||
parents: [commitSha],
|
||
}),
|
||
});
|
||
if (!newCommitResp.ok) {
|
||
const text = await newCommitResp.text();
|
||
throw new Error(`GitHub putBlob create-commit: ${newCommitResp.status} ${text}`);
|
||
}
|
||
const { sha: newCommitSha } = await newCommitResp.json() as { sha: string };
|
||
|
||
// 6. Fast-forward branch to the new commit.
|
||
const updateRefResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, {
|
||
method: 'PATCH',
|
||
headers: this.headers,
|
||
body: JSON.stringify({ sha: newCommitSha, force: false }),
|
||
});
|
||
if (!updateRefResp.ok) {
|
||
const text = await updateRefResp.text();
|
||
throw new Error(`GitHub putBlob update-ref: ${updateRefResp.status} ${text}`);
|
||
}
|
||
|
||
return path;
|
||
}
|
||
|
||
async getBlob(path: string): Promise<Uint8Array> {
|
||
return this.readFile(path);
|
||
}
|
||
|
||
async deleteBlob(path: string, message: string): Promise<void> {
|
||
return this.deleteFile(path, message);
|
||
}
|
||
```
|
||
|
||
Also: at the top of the file, ensure `import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host';` (add `BLOB_THRESHOLD_BYTES`).
|
||
|
||
- [ ] **Step 4: Re-run the new tests** — `cd extension && bun run test src/service-worker/__tests__/git-host.test.ts 2>&1 | tail -10`. Expected: 3 passed.
|
||
|
||
- [ ] **Step 5: Run full vitest** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 131 passed (was 128 + 3 new).
|
||
|
||
- [ ] **Step 6: Type-check** — `cd extension && bunx tsc --noEmit 2>&1 | tail -3`. Expected: zero errors.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/service-worker/github.ts \
|
||
extension/src/service-worker/__tests__/git-host.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/sw): GitHubHost.putBlob with Git Data API fallback
|
||
|
||
Blobs ≤ BLOB_THRESHOLD_BYTES (900 KB) take the Contents API path
|
||
(same as writeFile). Larger blobs use the Git Data API: POST blob,
|
||
GET ref + commit, POST tree (with base_tree), POST commit, PATCH ref.
|
||
Tests cover both paths plus error propagation.
|
||
|
||
getBlob/deleteBlob are thin wrappers over readFile/deleteFile.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: GiteaHost — putBlob with Git Data API fallback
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/service-worker/gitea.ts`
|
||
- Modify: `extension/src/service-worker/__tests__/git-host.test.ts` — add Gitea test cases
|
||
|
||
- [ ] **Step 1: Add Gitea test cases** at the end of `extension/src/service-worker/__tests__/git-host.test.ts`. Append:
|
||
|
||
```ts
|
||
import { GiteaHost } from '../gitea';
|
||
|
||
describe('GiteaHost.putBlob', () => {
|
||
beforeEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('uses Contents API when content is at threshold', async () => {
|
||
const fetchMock = setupFetch();
|
||
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' } as Response);
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({}) } as Response);
|
||
|
||
const host = new GiteaHost('https://git.example.com', REPO, TOKEN);
|
||
const small = new Uint8Array(BLOB_THRESHOLD_BYTES);
|
||
await host.putBlob('attachments/abc.bin', small, 'add abc');
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(fetchMock.mock.calls[1][0]).toContain('/contents/');
|
||
expect(fetchMock.mock.calls[1][1]?.method).toBe('PUT');
|
||
});
|
||
|
||
it('uses Git Data API fallback when content exceeds threshold', async () => {
|
||
const fetchMock = setupFetch();
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'blobsha111' }) } as Response);
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ object: { sha: 'commitsha111' } }) } as Response);
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ tree: { sha: 'treesha111' } }) } as Response);
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newtreesha111' }) } as Response);
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newcommitsha111' }) } as Response);
|
||
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) } as Response);
|
||
|
||
const host = new GiteaHost('https://git.example.com', REPO, TOKEN);
|
||
const big = new Uint8Array(BLOB_THRESHOLD_BYTES + 1);
|
||
await host.putBlob('attachments/big.bin', big, 'add big');
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(6);
|
||
// Gitea uses /api/v1/ prefix
|
||
expect(fetchMock.mock.calls[0][0]).toContain('/api/v1/repos/');
|
||
expect(fetchMock.mock.calls[0][0]).toContain('/git/blobs');
|
||
expect(fetchMock.mock.calls[5][1]?.method).toBe('PATCH');
|
||
});
|
||
});
|
||
```
|
||
|
||
Note: the GiteaHost constructor signature in the existing file is `constructor(hostUrl: string, repoPath: string, apiToken: string)`. The test creates with `new GiteaHost('https://git.example.com', REPO, TOKEN)` — verify against actual constructor signature when implementing.
|
||
|
||
- [ ] **Step 2: Run the new Gitea tests — they should FAIL** (stub still throws):
|
||
|
||
```bash
|
||
cd extension && bun run test src/service-worker/__tests__/git-host.test.ts 2>&1 | tail -10
|
||
```
|
||
|
||
Expected: 2 new tests fail (3 GitHub tests still pass).
|
||
|
||
- [ ] **Step 3: Implement real methods in `extension/src/service-worker/gitea.ts`**.
|
||
|
||
The GiteaHost constructor sets `baseUrl = {hostUrl}/api/v1/repos/{repoPath}/contents`. We need a `gitApiBase = {hostUrl}/api/v1/repos/{repoPath}/git`. Modify the constructor (similar pattern to Task 3):
|
||
|
||
```ts
|
||
export class GiteaHost implements GitHost {
|
||
private baseUrl: string;
|
||
private gitApiBase: string;
|
||
private branch: string = 'main';
|
||
private headers: Record<string, string>;
|
||
|
||
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
||
this.baseUrl = `${hostUrl}/api/v1/repos/${repoPath}/contents`;
|
||
this.gitApiBase = `${hostUrl}/api/v1/repos/${repoPath}/git`;
|
||
this.headers = {
|
||
'Authorization': `token ${apiToken}`,
|
||
'Content-Type': 'application/json',
|
||
};
|
||
}
|
||
```
|
||
|
||
(Verify auth header — GitHub uses `Bearer`, Gitea uses `token`. Existing GiteaHost code already handles this; preserve the existing format.)
|
||
|
||
Replace the three stub methods with implementations parallel to GitHub's. The body of `putBlob` is structurally identical to GitHub's — just uses `this.gitApiBase` (which already has the `/api/v1/` prefix). Copy the GitHub implementation verbatim, swapping `gitApiBase` references appropriately.
|
||
|
||
```ts
|
||
async putBlob(path: string, content: Uint8Array, message: string): Promise<string> {
|
||
if (content.length <= BLOB_THRESHOLD_BYTES) {
|
||
await this.writeFile(path, content, message);
|
||
return path;
|
||
}
|
||
|
||
// Git Data API fallback (Gitea v1; same shape as GitHub).
|
||
const blobResp = await fetch(`${this.gitApiBase}/blobs`, {
|
||
method: 'POST',
|
||
headers: this.headers,
|
||
body: JSON.stringify({
|
||
content: uint8ArrayToBase64(content),
|
||
encoding: 'base64',
|
||
}),
|
||
});
|
||
if (!blobResp.ok) {
|
||
const text = await blobResp.text();
|
||
throw new Error(`Gitea putBlob create-blob ${path}: ${blobResp.status} ${text}`);
|
||
}
|
||
const { sha: blobSha } = await blobResp.json() as { sha: string };
|
||
|
||
const refResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, { headers: this.headers });
|
||
if (!refResp.ok) {
|
||
throw new Error(`Gitea putBlob get-ref: ${refResp.status} ${refResp.statusText}`);
|
||
}
|
||
const { object: { sha: commitSha } } = await refResp.json() as { object: { sha: string } };
|
||
|
||
const commitResp = await fetch(`${this.gitApiBase}/commits/${commitSha}`, { headers: this.headers });
|
||
if (!commitResp.ok) {
|
||
throw new Error(`Gitea putBlob get-commit: ${commitResp.status} ${commitResp.statusText}`);
|
||
}
|
||
const { tree: { sha: baseTreeSha } } = await commitResp.json() as { tree: { sha: string } };
|
||
|
||
const treeResp = await fetch(`${this.gitApiBase}/trees`, {
|
||
method: 'POST',
|
||
headers: this.headers,
|
||
body: JSON.stringify({
|
||
base_tree: baseTreeSha,
|
||
tree: [{ path, mode: '100644', type: 'blob', sha: blobSha }],
|
||
}),
|
||
});
|
||
if (!treeResp.ok) {
|
||
const text = await treeResp.text();
|
||
throw new Error(`Gitea putBlob create-tree: ${treeResp.status} ${text}`);
|
||
}
|
||
const { sha: newTreeSha } = await treeResp.json() as { sha: string };
|
||
|
||
const newCommitResp = await fetch(`${this.gitApiBase}/commits`, {
|
||
method: 'POST',
|
||
headers: this.headers,
|
||
body: JSON.stringify({
|
||
message,
|
||
tree: newTreeSha,
|
||
parents: [commitSha],
|
||
}),
|
||
});
|
||
if (!newCommitResp.ok) {
|
||
const text = await newCommitResp.text();
|
||
throw new Error(`Gitea putBlob create-commit: ${newCommitResp.status} ${text}`);
|
||
}
|
||
const { sha: newCommitSha } = await newCommitResp.json() as { sha: string };
|
||
|
||
const updateRefResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, {
|
||
method: 'PATCH',
|
||
headers: this.headers,
|
||
body: JSON.stringify({ sha: newCommitSha, force: false }),
|
||
});
|
||
if (!updateRefResp.ok) {
|
||
const text = await updateRefResp.text();
|
||
throw new Error(`Gitea putBlob update-ref: ${updateRefResp.status} ${text}`);
|
||
}
|
||
|
||
return path;
|
||
}
|
||
|
||
async getBlob(path: string): Promise<Uint8Array> {
|
||
return this.readFile(path);
|
||
}
|
||
|
||
async deleteBlob(path: string, message: string): Promise<void> {
|
||
return this.deleteFile(path, message);
|
||
}
|
||
```
|
||
|
||
Add `BLOB_THRESHOLD_BYTES` to the imports at the top: `import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host';`.
|
||
|
||
- [ ] **Step 4: Re-run tests** — `cd extension && bun run test src/service-worker/__tests__/git-host.test.ts 2>&1 | tail -10`. Expected: 5 passed (3 GitHub + 2 Gitea).
|
||
|
||
- [ ] **Step 5: Full vitest + tsc** — `cd extension && bun run test && bunx tsc --noEmit`. Expected: 133 passed; zero TS errors.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/service-worker/gitea.ts \
|
||
extension/src/service-worker/__tests__/git-host.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/sw): GiteaHost.putBlob with Git Data API fallback
|
||
|
||
Same shape as GitHubHost — Gitea v1 /api/v1/ prefix; otherwise the
|
||
endpoint shapes are identical. 2 new tests; total 5 git-host tests.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Service-worker vault helpers — addAttachmentToItem + removeAttachmentsFromItem
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/service-worker/vault.ts` — add 2 helper functions
|
||
|
||
- [ ] **Step 1: Read existing `vault.ts`** to understand the encrypt/persist patterns it already uses for items + manifest. The new helpers will reuse those primitives.
|
||
|
||
- [ ] **Step 2: Append at the end of `extension/src/service-worker/vault.ts`** (after existing exports):
|
||
|
||
```ts
|
||
import type { AttachmentRef, Item } from '../shared/types';
|
||
|
||
/// Add an AttachmentRef to an existing item.
|
||
/// Reads + decrypts the item, appends to attachments[], re-encrypts and
|
||
/// writes both item.json and the manifest entry's attachment_summaries.
|
||
/// Throws if the item is not found.
|
||
export async function addAttachmentToItem(
|
||
itemId: string,
|
||
ref: AttachmentRef,
|
||
): Promise<void> {
|
||
const item = await loadItem(itemId);
|
||
if (!item) {
|
||
throw new Error(`addAttachmentToItem: item ${itemId} not found`);
|
||
}
|
||
item.attachments = [...item.attachments, ref];
|
||
item.modified = Math.floor(Date.now() / 1000);
|
||
await saveItem(item);
|
||
await syncManifestEntry(itemId);
|
||
}
|
||
|
||
/// Remove attachments matching the given IDs from the item.
|
||
/// Returns the removed AttachmentRefs (for the caller to delete underlying blobs).
|
||
/// If `idsToRemove` contains IDs not present, those are silently ignored.
|
||
export async function removeAttachmentsFromItem(
|
||
itemId: string,
|
||
idsToRemove: string[],
|
||
): Promise<AttachmentRef[]> {
|
||
const item = await loadItem(itemId);
|
||
if (!item) {
|
||
throw new Error(`removeAttachmentsFromItem: item ${itemId} not found`);
|
||
}
|
||
const removeSet = new Set(idsToRemove);
|
||
const removed: AttachmentRef[] = [];
|
||
const kept: AttachmentRef[] = [];
|
||
for (const att of item.attachments) {
|
||
if (removeSet.has(att.id)) {
|
||
removed.push(att);
|
||
} else {
|
||
kept.push(att);
|
||
}
|
||
}
|
||
item.attachments = kept;
|
||
item.modified = Math.floor(Date.now() / 1000);
|
||
await saveItem(item);
|
||
await syncManifestEntry(itemId);
|
||
return removed;
|
||
}
|
||
```
|
||
|
||
The functions reference `loadItem`, `saveItem`, `syncManifestEntry` — these are existing helpers in `vault.ts`. If a helper is named slightly differently in the actual file, adapt the calls to match. If `syncManifestEntry` doesn't exist, the implementer should create a small inline helper that re-encrypts the manifest entry's `attachment_summaries` from the item's current attachments and writes the manifest. Pattern:
|
||
|
||
```ts
|
||
async function syncManifestEntry(itemId: string): Promise<void> {
|
||
const manifest = await loadManifest();
|
||
const item = await loadItem(itemId);
|
||
if (!item || !manifest) return;
|
||
const entry = manifest.entries[itemId];
|
||
if (!entry) return;
|
||
entry.attachment_summaries = item.attachments.map((a) => ({
|
||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||
}));
|
||
await saveManifest(manifest);
|
||
}
|
||
```
|
||
|
||
If the existing `vault.ts` already auto-syncs the manifest on item save, drop the explicit `syncManifestEntry` calls — verify by reading the existing `saveItem` implementation.
|
||
|
||
- [ ] **Step 3: Verify type-check + tests** — `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`. Expected: zero TS errors; 133 passed (no new tests yet — these helpers are exercised via Task 6's router tests).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/service-worker/vault.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/sw): vault helpers for attachment add/remove
|
||
|
||
addAttachmentToItem appends an AttachmentRef + re-syncs the manifest
|
||
entry's attachment_summaries. removeAttachmentsFromItem returns the
|
||
removed refs so the caller can deleteBlob() the underlying bytes.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Router handlers — upload_attachment + download_attachment
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/shared/messages.ts` — add 2 new message types
|
||
- Modify: `extension/src/service-worker/router/popup-only.ts` — add 2 handlers
|
||
- Modify: `extension/src/service-worker/router/__tests__/router.test.ts` — add 4 cases (2 accept + 2 reject)
|
||
|
||
- [ ] **Step 1: Add message types** in `extension/src/shared/messages.ts`. Find the `PopupMessage` union (or wherever `update_vault_settings` is defined) and add:
|
||
|
||
```ts
|
||
| { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer }
|
||
| { type: 'download_attachment'; itemId: string; attachmentId: string }
|
||
```
|
||
|
||
Then find `POPUP_ONLY_TYPES` (the Set) and add `'upload_attachment'` and `'download_attachment'`.
|
||
|
||
Add response shapes (find the existing response-type union):
|
||
|
||
```ts
|
||
| { type: 'upload_attachment'; data: { attachment: AttachmentRef } }
|
||
| { type: 'download_attachment'; data: { bytes: ArrayBuffer; filename: string; mimeType: string } }
|
||
```
|
||
|
||
Make sure `AttachmentRef` is imported at the top of messages.ts: `import type { ..., AttachmentRef } from './types';`.
|
||
|
||
- [ ] **Step 2: Write failing router tests** in `extension/src/service-worker/router/__tests__/router.test.ts`. Append 4 test cases at the end of the relevant describe block. Pattern (model after the existing `update_vault_settings` cases):
|
||
|
||
```ts
|
||
describe('upload_attachment / download_attachment', () => {
|
||
it('upload_attachment accepted from popup', async () => {
|
||
const result = await routeMessage(
|
||
{ type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) },
|
||
popupSender,
|
||
);
|
||
expect(result.ok).toBeDefined(); // either ok:true or ok:false but reached the handler
|
||
});
|
||
|
||
it('upload_attachment rejected from content script', async () => {
|
||
const result = await routeMessage(
|
||
{ type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) },
|
||
contentSender,
|
||
);
|
||
expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||
});
|
||
|
||
it('download_attachment accepted from popup', async () => {
|
||
const result = await routeMessage(
|
||
{ type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' },
|
||
popupSender,
|
||
);
|
||
expect(result.ok).toBeDefined();
|
||
});
|
||
|
||
it('download_attachment rejected from content script', async () => {
|
||
const result = await routeMessage(
|
||
{ type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' },
|
||
contentSender,
|
||
);
|
||
expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||
});
|
||
});
|
||
```
|
||
|
||
(Adapt `popupSender` / `contentSender` / `routeMessage` to match the names used in the existing test file. The point is: assert each new message type lives in `POPUP_ONLY_TYPES` and the router rejects content-script senders.)
|
||
|
||
- [ ] **Step 3: Run tests — they should FAIL** because the handler cases don't exist yet:
|
||
|
||
```bash
|
||
cd extension && bun run test 2>&1 | tail -5
|
||
```
|
||
|
||
Expected: 4 new tests fail.
|
||
|
||
- [ ] **Step 4: Implement handlers** in `extension/src/service-worker/router/popup-only.ts`. Add to the switch statement (model after the existing `update_vault_settings` case):
|
||
|
||
```ts
|
||
case 'upload_attachment': {
|
||
try {
|
||
const session = getSessionHandle();
|
||
if (!session) return { ok: false, error: 'locked' };
|
||
|
||
// Encrypt the bytes via WASM.
|
||
const plaintext = new Uint8Array(msg.bytes);
|
||
const encrypted = wasm.attachment_encrypt(session, plaintext);
|
||
// encrypted is { id: string, bytes: Uint8Array }
|
||
|
||
// Upload via GitHost.putBlob.
|
||
const config = await getVaultConfig();
|
||
const host = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||
const blobPath = `${config.vaultRoot}/attachments/${encrypted.id}.bin`;
|
||
await host.putBlob(blobPath, encrypted.bytes, `attach: ${msg.filename}`);
|
||
|
||
// Build AttachmentRef and add to item.
|
||
const ref: AttachmentRef = {
|
||
id: encrypted.id,
|
||
filename: msg.filename,
|
||
mime_type: msg.mimeType,
|
||
size: plaintext.length,
|
||
created: Math.floor(Date.now() / 1000),
|
||
};
|
||
await addAttachmentToItem(msg.itemId, ref);
|
||
|
||
return { ok: true, data: { attachment: ref } };
|
||
} catch (e) {
|
||
return { ok: false, error: 'upload_failed', detail: String(e) };
|
||
}
|
||
}
|
||
|
||
case 'download_attachment': {
|
||
try {
|
||
const session = getSessionHandle();
|
||
if (!session) return { ok: false, error: 'locked' };
|
||
|
||
// Find the AttachmentRef on the item to recover filename + mime.
|
||
const item = await loadItem(msg.itemId);
|
||
if (!item) return { ok: false, error: 'item_not_found' };
|
||
const ref = item.attachments.find((a) => a.id === msg.attachmentId);
|
||
if (!ref) return { ok: false, error: 'attachment_not_found' };
|
||
|
||
const config = await getVaultConfig();
|
||
const host = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||
const blobPath = `${config.vaultRoot}/attachments/${msg.attachmentId}.bin`;
|
||
const encrypted = await host.getBlob(blobPath);
|
||
const decrypted = wasm.attachment_decrypt(session, encrypted);
|
||
|
||
return {
|
||
ok: true,
|
||
data: {
|
||
bytes: decrypted.buffer.slice(decrypted.byteOffset, decrypted.byteOffset + decrypted.byteLength),
|
||
filename: ref.filename,
|
||
mimeType: ref.mime_type,
|
||
},
|
||
};
|
||
} catch (e) {
|
||
return { ok: false, error: 'download_failed', detail: String(e) };
|
||
}
|
||
}
|
||
```
|
||
|
||
Add the necessary imports at the top:
|
||
```ts
|
||
import { addAttachmentToItem, removeAttachmentsFromItem, loadItem } from '../vault';
|
||
import type { AttachmentRef } from '../../shared/types';
|
||
```
|
||
|
||
(`wasm`, `getSessionHandle`, `getVaultConfig`, `createGitHost` should already be imported in this file — adapt to existing names.)
|
||
|
||
- [ ] **Step 5: Run tests** — `cd extension && bun run test 2>&1 | tail -5`. Expected: 137 passed (was 133 + 4 new).
|
||
|
||
- [ ] **Step 6: Type-check** — `cd extension && bunx tsc --noEmit 2>&1 | tail -5`. Expected: zero errors.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/shared/messages.ts \
|
||
extension/src/service-worker/router/popup-only.ts \
|
||
extension/src/service-worker/router/__tests__/router.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/sw): upload_attachment + download_attachment router handlers
|
||
|
||
Both popup-only. upload_attachment encrypts via WASM, putBlobs via
|
||
GitHost (Git Data API fallback for >900 KB), persists the AttachmentRef
|
||
on the item + manifest summaries. download_attachment reads + decrypts
|
||
and returns plaintext bytes for the popup to wrap in a Blob.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Popup attachments-disclosure component (+ CSS)
|
||
|
||
**Files:**
|
||
- Create: `extension/src/popup/components/attachments-disclosure.ts`
|
||
- Create: `extension/src/popup/components/__tests__/attachments-disclosure.test.ts`
|
||
- Modify: `extension/src/popup/styles.css` — add `.attachments-disclosure` rules
|
||
|
||
- [ ] **Step 1: Create the component** at `extension/src/popup/components/attachments-disclosure.ts`:
|
||
|
||
```ts
|
||
/// Compact disclosure pattern for attachments.
|
||
/// Renders inside any item form (edit) or detail (view) and manages
|
||
/// add (upload via SW) + remove (defer until form save) + thumb-icons
|
||
/// (lazy decrypt + object-URL lifecycle for image-mime rows).
|
||
|
||
import { sendMessage, escapeHtml } from '../popup';
|
||
import type { AttachmentRef } from '../../shared/types';
|
||
|
||
export type DisclosureMode = 'edit' | 'view';
|
||
|
||
export interface AttachmentsDisclosureOpts {
|
||
itemId: string; // item being edited/viewed (for upload + download messages)
|
||
attachments: AttachmentRef[]; // current attachments (read-only — caller maintains state)
|
||
mode: DisclosureMode;
|
||
onChange?: (next: AttachmentRef[]) => void; // edit mode only — caller updates draft
|
||
}
|
||
|
||
const formatBytes = (n: number): string => {
|
||
if (n < 1024) return `${n} B`;
|
||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||
};
|
||
|
||
const isImage = (mime: string): boolean => mime.startsWith('image/');
|
||
|
||
const objectUrlRegistry = new Map<string, string>(); // attachmentId → object URL
|
||
|
||
function teardownObjectUrls(): void {
|
||
for (const url of objectUrlRegistry.values()) {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
objectUrlRegistry.clear();
|
||
}
|
||
|
||
async function fetchThumbUrl(itemId: string, attachmentId: string, mime: string): Promise<string | null> {
|
||
if (objectUrlRegistry.has(attachmentId)) return objectUrlRegistry.get(attachmentId)!;
|
||
const resp = await sendMessage({ type: 'download_attachment', itemId, attachmentId });
|
||
if (!resp.ok) return null;
|
||
const data = resp.data as { bytes: ArrayBuffer };
|
||
const blob = new Blob([data.bytes], { type: mime });
|
||
const url = URL.createObjectURL(blob);
|
||
objectUrlRegistry.set(attachmentId, url);
|
||
return url;
|
||
}
|
||
|
||
export function renderAttachmentsDisclosure(opts: AttachmentsDisclosureOpts): string {
|
||
const count = opts.attachments.length;
|
||
const headerLabel = count === 0 ? 'attachments' : `attachments (${count})`;
|
||
const expanded = count > 0;
|
||
const rowsHtml = opts.attachments.map((a) => {
|
||
const action = opts.mode === 'edit' ? '×' : '↓';
|
||
const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download';
|
||
const iconHtml = isImage(a.mime_type)
|
||
? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">📄</span>`
|
||
: `<span class="attachment-row__icon">📄</span>`;
|
||
return `
|
||
<div class="attachment-row" data-att-id="${escapeHtml(a.id)}">
|
||
${iconHtml}
|
||
<span class="attachment-row__name">${escapeHtml(a.filename)}</span>
|
||
<span class="attachment-row__meta">${formatBytes(a.size)}</span>
|
||
<span class="${actionClass}" data-att-id="${escapeHtml(a.id)}">${action}</span>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
const addBtn = opts.mode === 'edit'
|
||
? `<button class="attachment-add-btn" type="button">+ attach file</button>`
|
||
: '';
|
||
return `
|
||
<details class="attachments-disclosure" ${expanded ? 'open' : ''}>
|
||
<summary>${expanded ? '▾' : '▸'} ${headerLabel}</summary>
|
||
<div class="attachments-disclosure__body">
|
||
${rowsHtml}
|
||
${addBtn}
|
||
</div>
|
||
<input type="file" class="attachments-disclosure__file-input" hidden />
|
||
</details>
|
||
`;
|
||
}
|
||
|
||
export function wireAttachmentsDisclosure(
|
||
root: HTMLElement,
|
||
opts: AttachmentsDisclosureOpts,
|
||
): void {
|
||
const disc = root.querySelector('.attachments-disclosure') as HTMLDetailsElement | null;
|
||
if (!disc) return;
|
||
|
||
// Lazy-load image thumbs whenever disclosure opens.
|
||
const loadThumbs = async (): Promise<void> => {
|
||
const thumbs = disc.querySelectorAll<HTMLElement>('.attachment-row__thumb');
|
||
for (const thumb of thumbs) {
|
||
const attId = thumb.dataset.attId;
|
||
const mime = thumb.dataset.mime;
|
||
if (!attId || !mime) continue;
|
||
const url = await fetchThumbUrl(opts.itemId, attId, mime);
|
||
if (url) {
|
||
thumb.innerHTML = `<img src="${url}" alt="" />`;
|
||
}
|
||
}
|
||
};
|
||
if (disc.open) loadThumbs();
|
||
disc.addEventListener('toggle', () => {
|
||
if (disc.open) loadThumbs();
|
||
else teardownObjectUrls();
|
||
});
|
||
|
||
// Edit mode: + attach file
|
||
if (opts.mode === 'edit') {
|
||
const fileInput = disc.querySelector('.attachments-disclosure__file-input') as HTMLInputElement;
|
||
const addBtn = disc.querySelector('.attachment-add-btn') as HTMLButtonElement | null;
|
||
addBtn?.addEventListener('click', () => fileInput?.click());
|
||
|
||
fileInput?.addEventListener('change', async () => {
|
||
const file = fileInput.files?.[0];
|
||
if (!file) return;
|
||
// TODO: cap-checks (per_attachment_max_bytes etc.)
|
||
const bytes = await file.arrayBuffer();
|
||
const resp = await sendMessage({
|
||
type: 'upload_attachment',
|
||
itemId: opts.itemId,
|
||
filename: file.name,
|
||
mimeType: file.type || 'application/octet-stream',
|
||
bytes,
|
||
});
|
||
if (resp.ok) {
|
||
const data = resp.data as { attachment: AttachmentRef };
|
||
opts.onChange?.([...opts.attachments, data.attachment]);
|
||
}
|
||
fileInput.value = ''; // allow re-pick of same file later
|
||
});
|
||
|
||
// Remove (×) buttons — defer the actual blob delete until form save
|
||
disc.querySelectorAll<HTMLElement>('.attachment-row__remove').forEach((btn) => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const attId = btn.dataset.attId;
|
||
if (!attId) return;
|
||
opts.onChange?.(opts.attachments.filter((a) => a.id !== attId));
|
||
});
|
||
});
|
||
}
|
||
|
||
// View mode: ↓ download
|
||
if (opts.mode === 'view') {
|
||
disc.querySelectorAll<HTMLElement>('.attachment-row__download').forEach((btn) => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.preventDefault();
|
||
const attId = btn.dataset.attId;
|
||
if (!attId) return;
|
||
const att = opts.attachments.find((a) => a.id === attId);
|
||
if (!att) return;
|
||
const resp = await sendMessage({ type: 'download_attachment', itemId: opts.itemId, attachmentId: attId });
|
||
if (!resp.ok) return;
|
||
const data = resp.data as { bytes: ArrayBuffer; filename: string; mimeType: string };
|
||
const blob = new Blob([data.bytes], { type: data.mimeType });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = data.filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Call from the parent component's teardown to release any image thumbs.
|
||
export function teardownAttachmentsDisclosure(): void {
|
||
teardownObjectUrls();
|
||
}
|
||
```
|
||
|
||
Note: the cap-check TODO is left as a comment. Implementer should add the actual cap check using `getState().vaultSettings?.attachment_caps` — fetch via `get_vault_settings` if not in state.
|
||
|
||
Replace the `// TODO: cap-checks` with:
|
||
|
||
```ts
|
||
const settings = (await sendMessage({ type: 'get_vault_settings' }))?.data as { settings: { attachment_caps: { per_attachment_max_bytes?: number; per_item_max_count?: number } } } | undefined;
|
||
const caps = settings?.settings.attachment_caps ?? {};
|
||
if (caps.per_attachment_max_bytes && file.size > caps.per_attachment_max_bytes) {
|
||
alert(`file too large (${formatBytes(file.size)} / cap ${formatBytes(caps.per_attachment_max_bytes)})`);
|
||
return;
|
||
}
|
||
if (caps.per_item_max_count && opts.attachments.length + 1 > caps.per_item_max_count) {
|
||
alert(`item attachment count would exceed per-item cap (${opts.attachments.length + 1} / ${caps.per_item_max_count})`);
|
||
return;
|
||
}
|
||
```
|
||
|
||
(Use `alert()` for now; γ₂ can wire a proper toast.)
|
||
|
||
- [ ] **Step 2: Write tests** at `extension/src/popup/components/__tests__/attachments-disclosure.test.ts`:
|
||
|
||
```ts
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
vi.mock('../../popup', async () => {
|
||
const sendMessage = vi.fn();
|
||
const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
||
return { sendMessage, escapeHtml };
|
||
});
|
||
|
||
import { renderAttachmentsDisclosure, wireAttachmentsDisclosure } from '../attachments-disclosure';
|
||
import { sendMessage } from '../../popup';
|
||
import type { AttachmentRef } from '../../../shared/types';
|
||
|
||
const REF1: AttachmentRef = { id: 'a1', filename: 'doc.pdf', mime_type: 'application/pdf', size: 12345, created: 1700000000 };
|
||
const REF2: AttachmentRef = { id: 'a2', filename: 'photo.png', mime_type: 'image/png', size: 240000, created: 1700000001 };
|
||
|
||
describe('attachments-disclosure render', () => {
|
||
it('renders empty state with no rows in edit mode', () => {
|
||
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
|
||
expect(html).toContain('attachments');
|
||
expect(html).toContain('+ attach file');
|
||
expect(html).not.toContain('attachment-row');
|
||
});
|
||
|
||
it('renders rows + remove buttons in edit mode', () => {
|
||
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange: vi.fn() });
|
||
expect(html).toContain('doc.pdf');
|
||
expect(html).toContain('photo.png');
|
||
expect(html).toContain('×');
|
||
expect(html).toContain('attachment-row__thumb'); // image-mime row gets thumb hook
|
||
});
|
||
|
||
it('renders rows + download buttons in view mode (no add btn)', () => {
|
||
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1], mode: 'view' });
|
||
expect(html).toContain('↓');
|
||
expect(html).not.toContain('+ attach file');
|
||
});
|
||
});
|
||
|
||
describe('attachments-disclosure wiring', () => {
|
||
beforeEach(() => {
|
||
vi.mocked(sendMessage).mockReset();
|
||
});
|
||
|
||
it('clicking + attach triggers file input click', () => {
|
||
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
|
||
const fileInput = document.querySelector('.attachments-disclosure__file-input') as HTMLInputElement;
|
||
const clickSpy = vi.spyOn(fileInput, 'click');
|
||
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
|
||
(document.querySelector('.attachment-add-btn') as HTMLButtonElement).click();
|
||
expect(clickSpy).toHaveBeenCalled();
|
||
});
|
||
|
||
it('clicking × calls onChange with the attachment removed', () => {
|
||
const onChange = vi.fn();
|
||
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange });
|
||
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange });
|
||
(document.querySelectorAll('.attachment-row__remove')[0] as HTMLElement).click();
|
||
expect(onChange).toHaveBeenCalledWith([REF2]);
|
||
});
|
||
|
||
it('clicking ↓ in view mode sends download_attachment', async () => {
|
||
vi.mocked(sendMessage).mockResolvedValueOnce({ ok: true, data: { bytes: new ArrayBuffer(10), filename: 'doc.pdf', mimeType: 'application/pdf' } });
|
||
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1], mode: 'view' });
|
||
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [REF1], mode: 'view' });
|
||
(document.querySelector('.attachment-row__download') as HTMLElement).click();
|
||
await new Promise((r) => setTimeout(r, 50));
|
||
expect(vi.mocked(sendMessage)).toHaveBeenCalledWith(expect.objectContaining({
|
||
type: 'download_attachment',
|
||
itemId: 'i1',
|
||
attachmentId: 'a1',
|
||
}));
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests** — `cd extension && bun run test 2>&1 | tail -5`. Expected: 143 passed (137 + 6 new).
|
||
|
||
- [ ] **Step 4: Add CSS** in `extension/src/popup/styles.css` (append at the end):
|
||
|
||
```css
|
||
/* --- attachments disclosure (γ₁) --- */
|
||
|
||
.attachments-disclosure {
|
||
margin: 8px 0;
|
||
border: 1px solid #30363d;
|
||
border-radius: 4px;
|
||
padding: 6px 8px;
|
||
font-size: 11px;
|
||
color: #8b949e;
|
||
}
|
||
.attachments-disclosure[open] {
|
||
border-color: #aa812a;
|
||
}
|
||
.attachments-disclosure summary {
|
||
cursor: pointer;
|
||
list-style: none;
|
||
outline: none;
|
||
user-select: none;
|
||
padding: 2px 0;
|
||
}
|
||
.attachments-disclosure summary::-webkit-details-marker { display: none; }
|
||
.attachments-disclosure summary:hover { color: #c9d1d9; }
|
||
.attachments-disclosure__body {
|
||
margin-top: 6px;
|
||
}
|
||
.attachment-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 0;
|
||
font-size: 10px;
|
||
border-bottom: 1px solid #21262d;
|
||
}
|
||
.attachment-row:last-of-type {
|
||
border-bottom: 0;
|
||
}
|
||
.attachment-row__icon,
|
||
.attachment-row__thumb {
|
||
width: 16px;
|
||
height: 16px;
|
||
background: #21262d;
|
||
border-radius: 2px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 10px;
|
||
flex-shrink: 0;
|
||
overflow: hidden;
|
||
}
|
||
.attachment-row__thumb img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.attachment-row__name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: #c9d1d9;
|
||
}
|
||
.attachment-row__meta {
|
||
color: #6e7681;
|
||
font-size: 9px;
|
||
font-family: ui-monospace, monospace;
|
||
flex-shrink: 0;
|
||
}
|
||
.attachment-row__remove,
|
||
.attachment-row__download {
|
||
color: #d2ab43;
|
||
cursor: pointer;
|
||
padding: 0 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
.attachment-row__remove { color: #ab2b20; }
|
||
.attachment-add-btn {
|
||
background: transparent;
|
||
border: 1px dashed #30363d;
|
||
color: #8b949e;
|
||
padding: 5px 8px;
|
||
font-size: 10px;
|
||
cursor: pointer;
|
||
border-radius: 3px;
|
||
width: 100%;
|
||
margin-top: 6px;
|
||
text-align: center;
|
||
}
|
||
.attachment-add-btn:hover {
|
||
border-color: #aa812a;
|
||
color: #c9d1d9;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Type-check + tests** — `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3`. Expected: zero TS errors; 143 passed.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/popup/components/attachments-disclosure.ts \
|
||
extension/src/popup/components/__tests__/attachments-disclosure.test.ts \
|
||
extension/src/popup/styles.css
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/popup): attachments-disclosure shared component
|
||
|
||
Compact disclosure rendering attachment rows with an action column
|
||
(× in edit, ↓ in view). Image-mime rows lazily decrypt + show a 16×16
|
||
thumb via object URLs; teardown revokes them on disclosure close. Edit
|
||
mode adds a "+ attach file" button wired to a hidden file input that
|
||
sends upload_attachment to the SW. 6 new tests; total ~143.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Wire disclosure into 6 type forms + item-list 📎 indicator
|
||
|
||
**Files:**
|
||
- Modify: 6 type files (`login.ts`, `secure-note.ts`, `identity.ts`, `card.ts`, `key.ts`, `totp.ts`)
|
||
- Modify: `extension/src/popup/components/item-list.ts` — add 📎 indicator
|
||
|
||
For each of the 6 type files, the disclosure is rendered+wired in two places: `renderForm` (edit mode) and `renderDetail` (view mode).
|
||
|
||
- [ ] **Step 1: For each type file, add the disclosure to renderForm**.
|
||
|
||
Pattern — search for the existing custom-fields disclosure call (e.g., `renderSectionsEditor(...)`) in `renderForm`. The attachments disclosure goes AFTER it, BEFORE the `<div class="form-actions">`.
|
||
|
||
Add at the top of each file:
|
||
```ts
|
||
import { renderAttachmentsDisclosure, wireAttachmentsDisclosure, teardownAttachmentsDisclosure } from '../attachments-disclosure';
|
||
```
|
||
|
||
In the form HTML template, after the sections-editor disclosure HTML, add:
|
||
```ts
|
||
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
|
||
```
|
||
|
||
Above the template, declare draft state:
|
||
```ts
|
||
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
||
```
|
||
|
||
After `wireSectionsEditor(...)`, add a local `wireDisclosure` helper that handles re-render + re-wire (so the onChange callback can recurse into itself cleanly):
|
||
|
||
```ts
|
||
const wireDisclosure = (): void => {
|
||
wireAttachmentsDisclosure(app, {
|
||
itemId: existing?.id ?? '',
|
||
attachments: attachmentsDraft,
|
||
mode: 'edit',
|
||
onChange: (next) => {
|
||
attachmentsDraft = next;
|
||
const disc = app.querySelector('.attachments-disclosure');
|
||
if (disc) {
|
||
disc.outerHTML = renderAttachmentsDisclosure({
|
||
itemId: existing?.id ?? '',
|
||
attachments: attachmentsDraft,
|
||
mode: 'edit',
|
||
});
|
||
wireDisclosure(); // re-attach handlers to the freshly rendered DOM
|
||
}
|
||
},
|
||
});
|
||
};
|
||
wireDisclosure();
|
||
```
|
||
|
||
In the save handler, replace `attachments: existing?.attachments ?? []` with `attachments: attachmentsDraft`.
|
||
|
||
In the form's `teardown()` function, add `teardownAttachmentsDisclosure()` to release any object URLs.
|
||
|
||
- [ ] **Step 2: For each type file, add the disclosure to renderDetail (view mode)**.
|
||
|
||
In `renderDetail`, after the typed fields and sections render, before the closing form tag, add:
|
||
```ts
|
||
${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })}
|
||
```
|
||
|
||
After the detail is mounted (in the function that wires up the detail — usually right after `app.innerHTML = ...`):
|
||
```ts
|
||
wireAttachmentsDisclosure(app, { itemId: item.id, attachments: item.attachments, mode: 'view' });
|
||
```
|
||
|
||
Same teardown call in the detail's `teardown()`.
|
||
|
||
- [ ] **Step 3: Wire the 📎 indicator in `extension/src/popup/components/item-list.ts`**.
|
||
|
||
Find the row template (around line 130–180 area). Locate the part that renders the title + favorite star. Add a `📎` span if `entry.attachment_summaries.length > 0`:
|
||
|
||
```ts
|
||
${entry.attachment_summaries.length > 0 ? '<span class="entry-row__attach-indicator" title="has attachments">📎</span>' : ''}
|
||
```
|
||
|
||
Add corresponding CSS in `styles.css`:
|
||
```css
|
||
.entry-row__attach-indicator {
|
||
font-size: 9px;
|
||
opacity: 0.6;
|
||
margin-right: 4px;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 143 passed (no new tests; existing tests should still pass since we didn't break behavior).
|
||
|
||
- [ ] **Step 5: Type-check** — `cd extension && bunx tsc --noEmit 2>&1 | tail -3`. Expected: zero errors.
|
||
|
||
- [ ] **Step 6: Build** — `cd extension && bun run build:all 2>&1 | tail -10`. Expected: 2 WASM warnings only.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/popup/components/types/login.ts \
|
||
extension/src/popup/components/types/secure-note.ts \
|
||
extension/src/popup/components/types/identity.ts \
|
||
extension/src/popup/components/types/card.ts \
|
||
extension/src/popup/components/types/key.ts \
|
||
extension/src/popup/components/types/totp.ts \
|
||
extension/src/popup/components/item-list.ts \
|
||
extension/src/popup/styles.css
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/popup): wire attachments disclosure into 6 type forms + 📎 list indicator
|
||
|
||
Each existing type form (Login, SecureNote, Identity, Card, Key, TOTP)
|
||
renders + wires the attachments-disclosure in both edit and view modes.
|
||
Form save reads from attachmentsDraft; teardown revokes any image
|
||
object URLs. Item-list rows show a 📎 glyph for items with at least
|
||
one attachment.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Document type — form + detail + signature-block CSS
|
||
|
||
**Files:**
|
||
- Create: `extension/src/popup/components/types/document.ts`
|
||
- Create: `extension/src/popup/components/types/__tests__/document.save.test.ts`
|
||
- Modify: `extension/src/popup/styles.css` — add `.document-signature-block` rules
|
||
|
||
- [ ] **Step 1: Create `extension/src/popup/components/types/document.ts`** modeled on the structure of `secure-note.ts` (one of the simpler types). Skeleton:
|
||
|
||
```ts
|
||
/// Document item type — title + REQUIRED primary attachment + optional
|
||
/// notes/tags/expires + optional supplementary attachments.
|
||
|
||
import { sendMessage, escapeHtml, getState, setState, navigate } from '../../popup';
|
||
import { renderSectionsEditor, wireSectionsEditor } from '../sections-editor';
|
||
import { renderAttachmentsDisclosure, wireAttachmentsDisclosure, teardownAttachmentsDisclosure } from '../attachments-disclosure';
|
||
import type { Item, AttachmentRef, MonthYear, Section } from '../../../shared/types';
|
||
|
||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||
|
||
export function teardown(): void {
|
||
if (activeKeyHandler) {
|
||
document.removeEventListener('keydown', activeKeyHandler);
|
||
activeKeyHandler = null;
|
||
}
|
||
teardownAttachmentsDisclosure();
|
||
}
|
||
|
||
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||
teardown();
|
||
|
||
const isEdit = mode === 'edit';
|
||
const c = existing?.core?.kind === 'document' ? existing.core : null;
|
||
|
||
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
||
let primaryId = c?.primary_attachment ?? '';
|
||
let sectionsDraft: Section[] = existing?.sections ?? [];
|
||
let sectionsExpanded = false;
|
||
|
||
const renderPrimary = (): string => {
|
||
const primaryRef = attachmentsDraft.find((a) => a.id === primaryId);
|
||
if (!primaryRef) {
|
||
return `
|
||
<div class="document-primary-row document-primary-row--empty" id="primary-picker">
|
||
+ attach primary file
|
||
<input type="file" id="primary-file-input" hidden />
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div class="document-primary-row" id="primary-picker">
|
||
<span class="document-primary-row__thumb">📄</span>
|
||
<span class="document-primary-row__name">${escapeHtml(primaryRef.filename)}</span>
|
||
<span class="document-primary-row__meta">${formatBytes(primaryRef.size)}</span>
|
||
<span class="document-primary-row__action" id="primary-change">↑ change</span>
|
||
<input type="file" id="primary-file-input" hidden />
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
const html = `
|
||
<div class="form pad">
|
||
<div class="form-title">${isEdit ? 'edit document' : 'new document'}</div>
|
||
|
||
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
|
||
<input id="f-title" type="text" value="${escapeHtml(existing?.title ?? '')}" placeholder="Passport, Lease, etc."></div>
|
||
|
||
<div class="form-group"><label class="label">primary attachment <span class="req">*</span></label>
|
||
${renderPrimary()}
|
||
</div>
|
||
|
||
<div class="form-group"><label class="label" for="f-notes">notes</label>
|
||
<textarea id="f-notes" rows="3" placeholder="optional context...">${escapeHtml(existing?.core?.kind === 'document' ? '' : '')}</textarea>
|
||
</div>
|
||
|
||
<div class="form-group"><label class="label" for="f-tags">tags</label>
|
||
<input id="f-tags" type="text" value="${escapeHtml((existing?.tags ?? []).join(', '))}" placeholder="legal, official"></div>
|
||
|
||
<div class="form-group"><label class="label">expires</label>
|
||
<div class="row" style="display:flex;gap:6px;">
|
||
<input id="f-exp-month" type="text" maxlength="2" placeholder="MM" style="max-width:50px;" />
|
||
<input id="f-exp-year" type="text" maxlength="4" placeholder="YYYY" style="max-width:60px;" />
|
||
</div>
|
||
</div>
|
||
|
||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
|
||
|
||
<div class="form-actions">
|
||
<button class="btn" id="cancel-btn">cancel</button>
|
||
<button class="btn btn-primary" id="save-btn">${getState().loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
app.innerHTML = html;
|
||
|
||
// Wire primary picker
|
||
const primaryFileInput = document.getElementById('primary-file-input') as HTMLInputElement;
|
||
const primaryPicker = document.getElementById('primary-picker');
|
||
primaryPicker?.addEventListener('click', (e) => {
|
||
if ((e.target as HTMLElement).id === 'primary-change' || primaryPicker.classList.contains('document-primary-row--empty')) {
|
||
primaryFileInput.click();
|
||
}
|
||
});
|
||
primaryFileInput?.addEventListener('change', async () => {
|
||
const file = primaryFileInput.files?.[0];
|
||
if (!file) return;
|
||
const bytes = await file.arrayBuffer();
|
||
const resp = await sendMessage({
|
||
type: 'upload_attachment',
|
||
itemId: existing?.id ?? '',
|
||
filename: file.name,
|
||
mimeType: file.type || 'application/octet-stream',
|
||
bytes,
|
||
});
|
||
if (resp.ok) {
|
||
const data = resp.data as { attachment: AttachmentRef };
|
||
attachmentsDraft = [...attachmentsDraft, data.attachment];
|
||
primaryId = data.attachment.id;
|
||
// Re-render primary section
|
||
const primaryGroup = document.querySelector('label[for=""]')?.closest('.form-group');
|
||
// Simpler: just call render again
|
||
renderForm(app, mode, existing);
|
||
}
|
||
});
|
||
|
||
// Wire supplementary attachments disclosure
|
||
wireAttachmentsDisclosure(app, {
|
||
itemId: existing?.id ?? '',
|
||
attachments: attachmentsDraft,
|
||
mode: 'edit',
|
||
onChange: (next) => {
|
||
attachmentsDraft = next;
|
||
// If the primary was removed, clear primaryId so save validation will block
|
||
if (!attachmentsDraft.find((a) => a.id === primaryId)) primaryId = '';
|
||
},
|
||
});
|
||
|
||
// Wire sections editor
|
||
const rerender = (): void => {
|
||
const disclosure = app.querySelector('.disclosure');
|
||
if (!disclosure) return;
|
||
sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true';
|
||
disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded);
|
||
wireSectionsEditor(app, sectionsDraft, rerender);
|
||
};
|
||
wireSectionsEditor(app, sectionsDraft, rerender);
|
||
|
||
// Cancel + save
|
||
document.getElementById('cancel-btn')?.addEventListener('click', () => navigate(isEdit ? 'detail' : 'list'));
|
||
|
||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||
if (!title) { alert('title required'); return; }
|
||
if (!primaryId || !attachmentsDraft.find((a) => a.id === primaryId)) {
|
||
alert('primary attachment required');
|
||
return;
|
||
}
|
||
const primaryRef = attachmentsDraft.find((a) => a.id === primaryId)!;
|
||
|
||
const monthRaw = (document.getElementById('f-exp-month') as HTMLInputElement).value.trim();
|
||
const yearRaw = (document.getElementById('f-exp-year') as HTMLInputElement).value.trim();
|
||
const expires: MonthYear | undefined =
|
||
monthRaw && yearRaw ? { month: Number(monthRaw), year: Number(yearRaw) } : undefined;
|
||
|
||
const tags = (document.getElementById('f-tags') as HTMLInputElement).value
|
||
.split(',').map((t) => t.trim()).filter(Boolean);
|
||
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const item: Item = {
|
||
schema_version: 2,
|
||
id: existing?.id ?? '',
|
||
type: 'document',
|
||
core: {
|
||
kind: 'document',
|
||
filename: primaryRef.filename,
|
||
mime_type: primaryRef.mime_type,
|
||
primary_attachment: primaryId,
|
||
},
|
||
title,
|
||
url: undefined,
|
||
notes: (document.getElementById('f-notes') as HTMLInputElement).value.trim() || undefined,
|
||
tags,
|
||
expires,
|
||
favorite: existing?.favorite ?? false,
|
||
created: existing?.created ?? now,
|
||
modified: now,
|
||
trashed_at: undefined,
|
||
sections: sectionsDraft,
|
||
attachments: attachmentsDraft,
|
||
field_history: existing?.field_history ?? {},
|
||
};
|
||
|
||
const resp = await sendMessage({ type: isEdit ? 'update_item' : 'create_item', item });
|
||
if (resp.ok) navigate('list');
|
||
else alert('save failed: ' + (resp.error ?? 'unknown'));
|
||
});
|
||
}
|
||
|
||
export function renderDetail(app: HTMLElement, item: Item): void {
|
||
teardown();
|
||
if (item.core.kind !== 'document') return;
|
||
const c = item.core;
|
||
const primaryRef = item.attachments.find((a) => a.id === c.primary_attachment);
|
||
if (!primaryRef) {
|
||
app.innerHTML = `<div class="pad"><p>document missing primary attachment</p></div>`;
|
||
return;
|
||
}
|
||
|
||
const expiresHtml = item.expires
|
||
? `<div class="form-group"><label class="label">expires</label><code>${String(item.expires.month).padStart(2,'0')}/${item.expires.year}</code></div>`
|
||
: '';
|
||
const tagsHtml = item.tags && item.tags.length > 0
|
||
? `<div class="form-group"><label class="label">tags</label><span>${item.tags.map((t) => escapeHtml(t)).join(', ')}</span></div>`
|
||
: '';
|
||
|
||
app.innerHTML = `
|
||
<div class="pad">
|
||
<div class="detail-title">${escapeHtml(item.title)}</div>
|
||
|
||
<div class="document-signature-block" id="doc-sigblock">
|
||
<div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">📄</div>
|
||
<div class="document-signature-block__info">
|
||
<div class="document-signature-block__name">${escapeHtml(primaryRef.filename)}</div>
|
||
<div class="document-signature-block__meta">${formatBytes(primaryRef.size)} · ${new Date(primaryRef.created * 1000).toISOString().slice(0,10)}</div>
|
||
<div class="document-signature-block__actions">
|
||
<span id="doc-download" style="cursor:pointer;color:#d2ab43;">↓ download</span>
|
||
${primaryRef.mime_type.startsWith('image/') ? '<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">🔍 preview</span>' : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${item.notes ? `<div class="form-group"><label class="label">notes</label><p>${escapeHtml(item.notes)}</p></div>` : ''}
|
||
${tagsHtml}
|
||
${expiresHtml}
|
||
|
||
${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments.filter((a) => a.id !== c.primary_attachment), mode: 'view' })}
|
||
|
||
<div class="form-actions">
|
||
<button class="btn" id="back-btn">← back</button>
|
||
<button class="btn" id="edit-btn">edit</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Wire signature-block thumb (if image, lazy-load)
|
||
if (primaryRef.mime_type.startsWith('image/')) {
|
||
const thumb = document.querySelector('.document-signature-block__thumb') as HTMLElement;
|
||
sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id }).then((resp) => {
|
||
if (!resp.ok) return;
|
||
const data = resp.data as { bytes: ArrayBuffer };
|
||
const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
|
||
const url = URL.createObjectURL(blob);
|
||
thumb.innerHTML = `<img src="${url}" alt="" />`;
|
||
});
|
||
}
|
||
|
||
document.getElementById('doc-download')?.addEventListener('click', async () => {
|
||
const resp = await sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id });
|
||
if (!resp.ok) return;
|
||
const data = resp.data as { bytes: ArrayBuffer };
|
||
const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = primaryRef.filename;
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||
});
|
||
|
||
document.getElementById('doc-preview')?.addEventListener('click', async () => {
|
||
const sigblock = document.getElementById('doc-sigblock')!;
|
||
const existingPreview = sigblock.querySelector('.document-signature-block__preview');
|
||
if (existingPreview) {
|
||
existingPreview.remove();
|
||
return;
|
||
}
|
||
const resp = await sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id });
|
||
if (!resp.ok) return;
|
||
const data = resp.data as { bytes: ArrayBuffer };
|
||
const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
|
||
const url = URL.createObjectURL(blob);
|
||
const preview = document.createElement('div');
|
||
preview.className = 'document-signature-block__preview';
|
||
preview.innerHTML = `<img src="${url}" alt="" />`;
|
||
sigblock.appendChild(preview);
|
||
});
|
||
|
||
// Wire supplementary attachments disclosure
|
||
wireAttachmentsDisclosure(app, {
|
||
itemId: item.id,
|
||
attachments: item.attachments.filter((a) => a.id !== c.primary_attachment),
|
||
mode: 'view',
|
||
});
|
||
|
||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||
document.getElementById('edit-btn')?.addEventListener('click', () => {
|
||
setState({ selectedItem: item });
|
||
navigate('edit');
|
||
});
|
||
}
|
||
|
||
function formatBytes(n: number): string {
|
||
if (n < 1024) return `${n} B`;
|
||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
```
|
||
|
||
(Implementer: tighten any deviations from existing type-component patterns to match codebase conventions. Some helper imports may be slightly differently named.)
|
||
|
||
- [ ] **Step 2: Add CSS for the signature block** in `styles.css` (append):
|
||
|
||
```css
|
||
/* --- Document signature block (γ₁) --- */
|
||
|
||
.document-signature-block {
|
||
border-left: 3px solid #aa812a;
|
||
background: #161b22;
|
||
padding: 10px;
|
||
margin: 8px 0;
|
||
border-radius: 0 4px 4px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.document-signature-block__thumb {
|
||
width: 48px;
|
||
height: 60px;
|
||
border-radius: 2px;
|
||
background: linear-gradient(135deg, #b88a30, #7c5719);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 20px;
|
||
flex-shrink: 0;
|
||
overflow: hidden;
|
||
color: #fff3cf;
|
||
}
|
||
.document-signature-block__thumb img {
|
||
width: 100%; height: 100%; object-fit: contain;
|
||
}
|
||
.document-signature-block__info { flex: 1; min-width: 0; }
|
||
.document-signature-block__name {
|
||
font-size: 11px;
|
||
color: #f1cf6e;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.document-signature-block__meta {
|
||
font-size: 9px;
|
||
color: #8b949e;
|
||
font-family: ui-monospace, monospace;
|
||
margin-top: 2px;
|
||
}
|
||
.document-signature-block__actions {
|
||
font-size: 9px;
|
||
margin-top: 4px;
|
||
}
|
||
.document-signature-block__preview {
|
||
margin-top: 8px;
|
||
background: #0d1117;
|
||
border: 1px solid #30363d;
|
||
border-radius: 3px;
|
||
padding: 6px;
|
||
text-align: center;
|
||
}
|
||
.document-signature-block__preview img {
|
||
max-width: 100%;
|
||
max-height: 200px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* Document primary picker (form mode) */
|
||
.document-primary-row {
|
||
background: #161b22;
|
||
border: 1px solid #30363d;
|
||
border-radius: 4px;
|
||
padding: 6px 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
}
|
||
.document-primary-row--empty {
|
||
border-style: dashed;
|
||
border-color: #aa812a;
|
||
color: #8b949e;
|
||
justify-content: center;
|
||
padding: 10px 8px;
|
||
}
|
||
.document-primary-row__thumb {
|
||
width: 18px; height: 18px;
|
||
border-radius: 2px;
|
||
background: linear-gradient(135deg, #b88a30, #7c5719);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 10px; flex-shrink: 0;
|
||
}
|
||
.document-primary-row__name {
|
||
flex: 1;
|
||
color: #c9d1d9;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.document-primary-row__meta {
|
||
color: #6e7681;
|
||
font-size: 9px;
|
||
font-family: ui-monospace, monospace;
|
||
}
|
||
.document-primary-row__action {
|
||
color: #d2ab43;
|
||
font-size: 10px;
|
||
padding: 0 6px;
|
||
cursor: pointer;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Write Document save tests** at `extension/src/popup/components/types/__tests__/document.save.test.ts`:
|
||
|
||
```ts
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
vi.mock('../../../popup', async () => {
|
||
const actual = await vi.importActual<typeof import('../../../popup')>('../../../popup');
|
||
return {
|
||
...actual,
|
||
sendMessage: vi.fn(),
|
||
getState: () => ({ loading: false }),
|
||
setState: vi.fn(),
|
||
navigate: vi.fn(),
|
||
escapeHtml: (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!)),
|
||
};
|
||
});
|
||
|
||
import { renderForm } from '../document';
|
||
import { sendMessage } from '../../../popup';
|
||
import type { Item, AttachmentRef } from '../../../../shared/types';
|
||
|
||
const PRIMARY: AttachmentRef = { id: 'primaryid', filename: 'passport.pdf', mime_type: 'application/pdf', size: 240000, created: 1700000000 };
|
||
|
||
describe('Document form save', () => {
|
||
beforeEach(() => {
|
||
vi.mocked(sendMessage).mockReset();
|
||
document.body.innerHTML = '<div id="app"></div>';
|
||
});
|
||
|
||
it('rejects save when primary_attachment is missing', () => {
|
||
const app = document.getElementById('app')!;
|
||
renderForm(app, 'add', null);
|
||
(document.getElementById('f-title') as HTMLInputElement).value = 'Passport';
|
||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||
(document.getElementById('save-btn') as HTMLButtonElement).click();
|
||
expect(alertSpy).toHaveBeenCalledWith('primary attachment required');
|
||
expect(vi.mocked(sendMessage)).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('sends create_item with correct wire shape when valid', async () => {
|
||
vi.mocked(sendMessage).mockResolvedValue({ ok: true });
|
||
const existingDraft: Item = {
|
||
schema_version: 2, id: '', type: 'document',
|
||
core: { kind: 'document', filename: PRIMARY.filename, mime_type: PRIMARY.mime_type, primary_attachment: PRIMARY.id },
|
||
title: 'Passport', url: undefined, notes: undefined, tags: [],
|
||
expires: undefined, favorite: false, created: 0, modified: 0,
|
||
trashed_at: undefined, sections: [], attachments: [PRIMARY], field_history: {},
|
||
};
|
||
const app = document.getElementById('app')!;
|
||
renderForm(app, 'edit', existingDraft);
|
||
(document.getElementById('f-title') as HTMLInputElement).value = 'Passport';
|
||
(document.getElementById('save-btn') as HTMLButtonElement).click();
|
||
await new Promise((r) => setTimeout(r, 50));
|
||
const lastCall = vi.mocked(sendMessage).mock.calls[vi.mocked(sendMessage).mock.calls.length - 1]![0] as any;
|
||
expect(lastCall.type).toBe('update_item');
|
||
expect(lastCall.item.core.primary_attachment).toBe(PRIMARY.id);
|
||
expect(lastCall.item.attachments).toEqual([PRIMARY]);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 145 passed (143 + 2 new).
|
||
|
||
- [ ] **Step 5: Type-check + build** — `cd extension && bunx tsc --noEmit && bun run build:all 2>&1 | tail -10`. Expected: zero TS errors; 2 WASM warnings only.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/popup/components/types/document.ts \
|
||
extension/src/popup/components/types/__tests__/document.save.test.ts \
|
||
extension/src/popup/styles.css
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/popup): Document item type — form + signature-block detail
|
||
|
||
Form requires title + primary_attachment; the primary-row picker is
|
||
compact in edit mode (dashed-border when empty, filename row when
|
||
filled). Detail view promotes the primary to a gold signature block
|
||
(48×60 thumb + filename + meta + ↓ download · 🔍 preview). For image-
|
||
mime primaries, the thumb lazy-loads via decrypt + object-URL; the
|
||
preview button toggles an inline expanded view.
|
||
|
||
Supplementary attachments use the standard compact disclosure (Task 7).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Wire Document into the form dispatcher
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/popup/components/item-form.ts` — replace the "coming soon" Document case
|
||
- Modify: `extension/src/popup/components/item-list.ts` — drop the `disabled: true` flag on Document in the type chooser
|
||
|
||
- [ ] **Step 1: Update `extension/src/popup/components/item-form.ts`** dispatcher.
|
||
|
||
Find:
|
||
```ts
|
||
import * as totp from './types/totp';
|
||
```
|
||
|
||
Add:
|
||
```ts
|
||
import * as document from './types/document';
|
||
```
|
||
|
||
Find the switch statement (around line 24):
|
||
```ts
|
||
case 'document': return renderComingSoon(app, type);
|
||
```
|
||
|
||
Replace with:
|
||
```ts
|
||
case 'document': return document.renderForm(app, mode, existing);
|
||
```
|
||
|
||
Also call `document.teardown()` in the teardown sequence at the top of `renderItemForm`:
|
||
```ts
|
||
totp.teardown();
|
||
document.teardown();
|
||
```
|
||
|
||
- [ ] **Step 2: Update `extension/src/popup/components/item-list.ts`** — find the type chooser (mentioned `'coming in γ — needs attachment upload'`):
|
||
|
||
```ts
|
||
{ type: 'document', icon: '📄', label: 'document', disabled: true, tooltip: 'coming in γ — needs attachment upload' },
|
||
```
|
||
|
||
Replace with:
|
||
```ts
|
||
{ type: 'document', icon: '📄', label: 'document' },
|
||
```
|
||
|
||
- [ ] **Step 3: Find renderDetail dispatcher** (probably in `item-detail.ts`). Same pattern — add Document case routing to `document.renderDetail(app, item)`.
|
||
|
||
```ts
|
||
case 'document': return document.renderDetail(app, item);
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests** — `cd extension && bun run test 2>&1 | tail -3`. Expected: 145 passed.
|
||
|
||
- [ ] **Step 5: Type-check + build** — `cd extension && bunx tsc --noEmit && bun run build:all 2>&1 | tail -10`. Expected: zero TS errors; 2 WASM warnings only.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/popup/components/item-form.ts \
|
||
extension/src/popup/components/item-list.ts \
|
||
extension/src/popup/components/item-detail.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(ext/popup): wire Document type into form + detail dispatchers
|
||
|
||
Document is no longer 'coming soon' — the type chooser unlocks it,
|
||
form dispatcher routes to document.renderForm, detail dispatcher
|
||
routes to document.renderDetail. teardown chains include document.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Build, full verification, manual smoke
|
||
|
||
Working dir: `/home/alee/Sources/relicario`. Branch: main.
|
||
|
||
- [ ] **Step 1: Full test sweep**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario && cargo test --workspace 2>&1 | grep -E "test result|running [0-9]+ tests" | tail -20
|
||
cd /home/alee/Sources/relicario/extension && bun run test 2>&1 | tail -5
|
||
cd /home/alee/Sources/relicario/extension && bunx tsc --noEmit 2>&1 | tail -5
|
||
```
|
||
|
||
Expected: 155 Rust + 145 Vitest, all green; tsc zero errors.
|
||
|
||
- [ ] **Step 2: Build both bundles**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension && bun run build:all 2>&1 | tail -10
|
||
```
|
||
|
||
Expected: 2 WASM warnings only for both Chrome and Firefox.
|
||
|
||
- [ ] **Step 3: Manual smoke test (relay to user)**
|
||
|
||
Reload the extension and walk through:
|
||
|
||
- **Login form attachment:** New Login → fill basics → expand `▾ attachments` → click "+ attach file" → pick small PDF (<900 KB) → row appears with size → save item → reopen → attachment row visible with ↓ → click ↓ → file downloads correctly. Decrypt works.
|
||
- **Image attachment thumb:** New Login → attach a small PNG → save → reopen → expand attachments disclosure → image row should show a tiny thumb instead of the generic 📄 icon. Close disclosure → re-open → thumb still works (object URL revoked + re-created).
|
||
- **Large file (Git Data API path):** Attach a >1 MB file. Should still upload successfully (slower). Confirm in the git host that a new commit landed.
|
||
- **Document type:** New → Document → fill title → click "+ attach primary file" → pick a PDF → primary row populates → save → detail view shows the gold signature block + filename + meta + ↓ download. Click ↓ → downloads. For image primaries, 🔍 preview shows inline.
|
||
- **Item-list 📎 indicator:** Items with at least one attachment show a small 📎 in the row.
|
||
- **Removal lifecycle:** Edit a Login that has 2 attachments → click × on one → save → reopen → only one attachment remains. Verify in git host the underlying blob was deleted.
|
||
|
||
- [ ] **Step 4: Final lint sweep — confirm no TODOs leaked**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario && grep -rn 'TODO.*cap-checks\|coming-soon.*document\|coming in γ' extension/src/
|
||
```
|
||
|
||
Expected: no output.
|
||
|
||
- [ ] **Step 5: Verify the slice's commit chain**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario && git log --oneline 6f5ef43..HEAD
|
||
```
|
||
|
||
Expected: ~10 commits — Tasks 1-10. (Task 11 doesn't commit unless something broke.)
|
||
|
||
- [ ] **Step 6: Working tree clean**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario && git status --short
|
||
```
|
||
|
||
Expected: only `.claude/` may be untracked.
|
||
|
||
---
|
||
|
||
## Verification summary
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension && bun run build:all
|
||
cd /home/alee/Sources/relicario && cargo test --workspace
|
||
cd /home/alee/Sources/relicario/extension && bun run test
|
||
cd /home/alee/Sources/relicario/extension && bunx tsc --noEmit
|
||
```
|
||
|
||
All four commands must succeed for the slice to be considered complete.
|
||
|
||
---
|
||
|
||
## Notes for the implementer
|
||
|
||
- **No worktree** — direct commits to main per project's single-maintainer flow (the user has explicitly approved this for prior slices).
|
||
- **Order matters strictly** — Tasks 2-4 ship interface + impls; Task 5 uses them; Task 6 uses Task 5; Task 7 introduces the shared component; Task 8 wires it; Task 9 adds Document; Task 10 unblocks Document; Task 11 verifies.
|
||
- **Atomic commits per task** — gen-UX redesign Task 2 was incorrectly split into 2 commits leaving a broken intermediate. For each task here, stage all changes first, then commit ONCE. The build must be green at every commit.
|
||
- **Existing patterns to follow:**
|
||
- The router test pattern (Task 6) — model on existing `update_vault_settings` cases
|
||
- The type-component shape (Task 9) — model on `secure-note.ts` (simplest existing form)
|
||
- The disclosure pattern (Task 7) — visually mirrors the custom-fields disclosure from β₂ (`extension/src/popup/components/sections-editor.ts`)
|
||
- **Don't re-export old popover types from generator-panel** — that module was renamed in the gen-UX redesign; ignore.
|
||
- **The `popup.ts`-side `humanizeError` helper** — can be used in error toasts if it exists in the codebase; otherwise `alert()` is fine for v1.
|
||
- **Attachment decryption renders a Blob in popup memory** — fine for typical file sizes; very large files (>50 MB) may hit memory pressure. Out of scope for γ₁.
|