mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
COD-39 attachment history drawer
Stacks on COD-38: accumulates a per-session attachment history and exposes it through a slide-in drawer with an unread badge, so attachments stay reachable after their cards are dismissed. Backend: - session-attachment-history: history state — dedupe by source path / relative path, newest-first, 100-item cap, and externalPath sanitization (the absolute host path is server-private and never leaves toState()). - session.ts: _attachmentHistory + getter (sanitized) / upsert / restore / getAttachmentHistoryForPersist; restored from saved state in the constructor. - file-routes: GET /attachments (list — resolves each entry to live metadata + routes; external entries are re-registered) and GET /attachments/:id (metadata poll). The by-id route guards via the registry's TOCTOU-safe resolveServableAttachmentPath. - server.ts: detected/registered attachments upsert into history and persist; the private (externalPath-bearing) history rides on disk under __attachmentHistory, separate from the sanitized public copy, and is restored on mux-session recovery. - types/session.ts: SessionAttachmentHistoryItem + SessionState.attachmentHistory. Frontend: - panels-ui: the drawer (lazy-built), unread badge, list render with per-item preview/download/open/"Card" (reshow) actions, and live refresh of the open drawer on new detections. - app.js: history state + per-session badge/cleanup wiring. - index.html / styles.css / mobile.css: header button + badge and the drawer. Verified: tsc / eslint / prettier / frontend-syntax / public-assets clean; new history-module unit tests pass; full test:ci green (2866 passed); badge, drawer open/render/reshow/close verified in-browser.
This commit is contained in:
committed by
Claude (Codeman maintainer)
parent
5eacb1cf03
commit
577b6d7384
@@ -0,0 +1,92 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { basename, extname } from 'node:path';
|
||||
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
||||
import type { AttachmentDetectedEvent } from './types/tools.js';
|
||||
import { getAttachmentType } from './attachment-registry.js';
|
||||
|
||||
export const ATTACHMENT_HISTORY_LIMIT = 100;
|
||||
|
||||
export interface ExternalAttachmentHistoryInput {
|
||||
sessionId: string;
|
||||
externalPath: string;
|
||||
fileName?: string;
|
||||
extension?: string;
|
||||
size: number;
|
||||
mtimeMs?: number;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export function normalizeAttachmentExtension(extensionOrPath: string): string {
|
||||
const value = extensionOrPath.startsWith('.') ? extensionOrPath : extname(extensionOrPath) || extensionOrPath;
|
||||
return value.toLowerCase().replace(/^\./, '');
|
||||
}
|
||||
|
||||
function historyKey(item: SessionAttachmentHistoryItem): string {
|
||||
if (item.source === 'external' && item.externalPath) {
|
||||
return `external:${item.externalPath}`;
|
||||
}
|
||||
return `detected:${item.relativePath || item.fileName}`;
|
||||
}
|
||||
|
||||
function safeExternalHistoryId(item: SessionAttachmentHistoryItem): string {
|
||||
const source = item.externalPath || item.id || item.fileName;
|
||||
const digest = createHash('sha256').update(source).digest('hex').slice(0, 16);
|
||||
return `external:${digest}:${item.fileName}`;
|
||||
}
|
||||
|
||||
export function sanitizeAttachmentHistoryItem(item: SessionAttachmentHistoryItem): SessionAttachmentHistoryItem {
|
||||
const { externalPath: _externalPath, ...safe } = item;
|
||||
return {
|
||||
...safe,
|
||||
id: item.source === 'external' ? safeExternalHistoryId(item) : item.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeAttachmentHistory(
|
||||
history: readonly SessionAttachmentHistoryItem[]
|
||||
): SessionAttachmentHistoryItem[] {
|
||||
return history.map(sanitizeAttachmentHistoryItem);
|
||||
}
|
||||
|
||||
export function upsertAttachmentHistory(
|
||||
history: readonly SessionAttachmentHistoryItem[],
|
||||
item: SessionAttachmentHistoryItem
|
||||
): SessionAttachmentHistoryItem[] {
|
||||
const nextKey = historyKey(item);
|
||||
return [item, ...history.filter((existing) => historyKey(existing) !== nextKey)].slice(0, ATTACHMENT_HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
export function buildDetectedAttachmentHistoryItem(event: AttachmentDetectedEvent): SessionAttachmentHistoryItem {
|
||||
return {
|
||||
id: `detected:${event.relativePath || event.fileName}`,
|
||||
sessionId: event.sessionId,
|
||||
fileName: event.fileName,
|
||||
extension: normalizeAttachmentExtension(event.extension),
|
||||
attachmentType: event.attachmentType,
|
||||
size: event.size,
|
||||
mtimeMs: 0,
|
||||
timestamp: event.timestamp,
|
||||
source: 'detected',
|
||||
relativePath: event.relativePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExternalAttachmentHistoryItem(
|
||||
input: ExternalAttachmentHistoryInput
|
||||
): SessionAttachmentHistoryItem {
|
||||
const extension = normalizeAttachmentExtension(input.extension || input.fileName || input.externalPath);
|
||||
return {
|
||||
id: `external:${createHash('sha256').update(input.externalPath).digest('hex').slice(0, 16)}:${
|
||||
input.fileName || basename(input.externalPath)
|
||||
}`,
|
||||
sessionId: input.sessionId,
|
||||
fileName: input.fileName || basename(input.externalPath),
|
||||
extension,
|
||||
attachmentType: getAttachmentType(extension),
|
||||
size: input.size,
|
||||
mtimeMs: input.mtimeMs ?? 0,
|
||||
timestamp: input.timestamp ?? Date.now(),
|
||||
source: 'external',
|
||||
externalPath: input.externalPath,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user