agent-ecosystem/src/renderer/utils/diffViewedStorage.ts
iliya 190cafdb8e feat: implement diff view with 4 phases — review, accept/reject, task scoping, enhanced UX
Phase 1: Core diff extraction and display
- ChangeExtractorService: JSONL streaming parser with snippet extraction
- FileContentResolver: 3-level content resolution (file-history → snippets → disk)
- ReviewApplierService: hunk-level accept/reject with conflict detection
- CodeMirrorDiffView: unified merge view with syntax highlighting
- ReviewFileTree: file browser with status indicators
- changeReviewSlice: Zustand state for review workflow

Phase 2: Interactive review with accept/reject
- Per-hunk and per-file accept/reject decisions
- Conflict checking before apply
- ReviewToolbar with bulk actions
- DiffErrorBoundary for graceful degradation

Phase 3: Per-task change scoping
- TaskBoundaryParser: detects task boundaries in JSONL (Tier 1-4 confidence)
- TaskChangeSetV2 with scope + warnings
- ConfidenceBadge and ScopeWarningBanner components

Phase 4: Enhanced features
- Keyboard navigation (j/k/n/p/a/x shortcuts via useDiffNavigation)
- Viewed file tracking (localStorage + useViewedFiles hook)
- File edit timeline (chronological events per file)
- Git fallback (GitDiffFallback service for incomplete JSONL data)
- Auto-viewed detection (IntersectionObserver sentinel)
2026-02-24 23:39:41 +02:00

120 lines
3.5 KiB
TypeScript

/**
* localStorage-based tracking of "viewed" files in diff review.
* Pattern follows teamMessageReadStorage.ts.
*/
const STORAGE_PREFIX = 'diff-viewed';
const MAX_TOTAL_ENTRIES = 50;
interface ViewedStorageEntry {
files: string[];
updatedAt: string;
}
function getStorageKey(teamName: string, scopeKey: string): string {
return `${STORAGE_PREFIX}:${teamName}:${scopeKey}`;
}
function parseEntry(raw: string | null): ViewedStorageEntry | null {
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
// Migration from old format (plain string[]) → new format
if (Array.isArray(parsed)) {
return { files: parsed as string[], updatedAt: new Date(0).toISOString() };
}
if (
parsed &&
typeof parsed === 'object' &&
'files' in parsed &&
Array.isArray((parsed as ViewedStorageEntry).files)
) {
return parsed as ViewedStorageEntry;
}
return null;
} catch {
return null;
}
}
function saveEntry(teamName: string, scopeKey: string, entry: ViewedStorageEntry): void {
try {
localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(entry));
} catch (error) {
console.warn('[diffViewedStorage] localStorage write failed:', error);
try {
cleanupOldViewedEntries();
localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(entry));
} catch {
// Full failure — silently ignore, viewed state is not critical
}
}
}
/** Cleanup old entries when localStorage is full */
export function cleanupOldViewedEntries(): void {
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(STORAGE_PREFIX)) keys.push(key);
}
if (keys.length > MAX_TOTAL_ENTRIES) {
const sorted = keys
.map((k) => ({ key: k, entry: parseEntry(localStorage.getItem(k)) }))
.sort((a, b) => (a.entry?.updatedAt ?? '').localeCompare(b.entry?.updatedAt ?? ''));
for (let i = 0; i < sorted.length - MAX_TOTAL_ENTRIES; i++) {
localStorage.removeItem(sorted[i].key);
}
}
}
/** Get set of viewed file paths */
export function getViewedFiles(teamName: string, scopeKey: string): Set<string> {
const entry = parseEntry(localStorage.getItem(getStorageKey(teamName, scopeKey)));
return entry ? new Set(entry.files) : new Set();
}
/** Mark a file as viewed */
export function markFileViewed(teamName: string, scopeKey: string, filePath: string): void {
const set = getViewedFiles(teamName, scopeKey);
set.add(filePath);
saveEntry(teamName, scopeKey, {
files: [...set],
updatedAt: new Date().toISOString(),
});
}
/** Unmark a file as viewed */
export function unmarkFileViewed(teamName: string, scopeKey: string, filePath: string): void {
const set = getViewedFiles(teamName, scopeKey);
set.delete(filePath);
if (set.size === 0) {
try {
localStorage.removeItem(getStorageKey(teamName, scopeKey));
} catch {
// ignore
}
return;
}
saveEntry(teamName, scopeKey, {
files: [...set],
updatedAt: new Date().toISOString(),
});
}
/** Mark all files as viewed */
export function markAllViewed(teamName: string, scopeKey: string, filePaths: string[]): void {
saveEntry(teamName, scopeKey, {
files: filePaths,
updatedAt: new Date().toISOString(),
});
}
/** Clear all viewed marks */
export function clearViewed(teamName: string, scopeKey: string): void {
try {
localStorage.removeItem(getStorageKey(teamName, scopeKey));
} catch {
// ignore
}
}