open-design/apps/web/src/components/DesignFilesPanel.tsx
2026-05-31 13:23:27 +08:00

1876 lines
68 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@open-design/components';
import { useAnalytics } from '../analytics/provider';
import { trackFileManagerClick } from '../analytics/events';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { projectFileUrl, projectRawUrl } from '../providers/registry';
import { buildSrcdoc } from '../runtime/srcdoc';
import type { LiveArtifactWorkspaceEntry, ProjectFile, ProjectFileKind } from '../types';
import {
createFileSystemReadError,
FILE_SYSTEM_READ_ERROR_MESSAGE,
isFileSystemReadError,
} from '../utils/fileSystemErrors';
import type { PluginFolderAgentAction } from './design-files/pluginFolderActions';
import { getPluginFolderCandidates } from './design-files/pluginFolders';
import { Icon } from './Icon';
import { LiveArtifactBadges } from './LiveArtifactBadges';
import { isRenderableSketchJson, SketchPreview } from './SketchPreview';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
interface Props {
projectId: string;
files: ProjectFile[];
liveArtifacts: LiveArtifactWorkspaceEntry[];
onRefreshFiles: () => Promise<void> | void;
onOpenFile: (name: string) => void;
onOpenLiveArtifact: (tabId: LiveArtifactWorkspaceEntry['tabId']) => void;
onRenameFile: (from: string, to: string) => Promise<ProjectFile | null> | ProjectFile | null;
onDeleteFile: (name: string) => void;
onDeleteFiles: (names: string[]) => Promise<void> | void;
onUpload: () => void;
onUploadFiles: (files: File[]) => void;
onPaste: () => void;
onNewSketch: () => void;
uploadError?: string | null;
onClearUploadError?: () => void;
onPluginFolderAgentAction?: (
relativePath: string,
action: PluginFolderAgentAction,
) => Promise<{ message?: string; url?: string } | void> | { message?: string; url?: string } | void;
activePluginActionPaths?: Set<string>;
hiddenPluginActionPaths?: Set<string>;
}
interface ActionNotice {
message: string;
url?: string;
}
type DesignFilesGroupMode = 'kind' | 'modified';
type ModifiedSection = 'today' | 'yesterday' | 'previous7Days' | 'previous30Days' | 'older';
type SortKey = 'name' | 'kind' | 'mtime';
type SortDir = 'asc' | 'desc';
// Storage key for per-project view state. Bump the version suffix (v1 → v2) when
// removing or renaming a persisted field — just adding an optional field is safe
// without a version bump. No cleanup of old keys on project deletion; the keys
// are small preference blobs and orphan gracefully.
const VIEW_STATE_KEY_PREFIX = 'od:design-files:view-state:v1:';
const DEFAULT_SORT_KEY: SortKey = 'mtime';
const DEFAULT_SORT_DIR: SortDir = 'desc';
const DEFAULT_PAGE_SIZE: number | 'all' = 30;
const PAGE_SIZE_OPTIONS = [15, 30, 45, 60, 'all'] as const;
interface PersistedViewState {
sortKey?: SortKey;
sortDir?: SortDir;
pageSize?: number | 'all';
kindFilter?: string[];
}
function readViewState(projectId: string): PersistedViewState {
try {
if (typeof window === 'undefined') return {};
const raw = localStorage.getItem(VIEW_STATE_KEY_PREFIX + projectId);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as PersistedViewState;
} catch {
return {};
}
}
function writeViewState(projectId: string, state: PersistedViewState): void {
try {
localStorage.setItem(VIEW_STATE_KEY_PREFIX + projectId, JSON.stringify(state));
} catch {
// localStorage unavailable (private mode, quota exceeded) — silently skip
}
}
function isSortKey(v: unknown): v is SortKey {
return v === 'name' || v === 'kind' || v === 'mtime';
}
function isSortDir(v: unknown): v is SortDir {
return v === 'asc' || v === 'desc';
}
function isPageSize(v: unknown): v is number | 'all' {
return (PAGE_SIZE_OPTIONS as ReadonlyArray<unknown>).includes(v);
}
// Validate that a value is one of the known ProjectFileKind literals. This
// guards against stored values that were valid under a previous schema but
// are no longer part of the union — they are silently dropped rather than
// poisoning the kindFilter state.
const VALID_KIND_SET: ReadonlySet<string> = new Set<ProjectFileKind>([
'html', 'image', 'video', 'audio', 'sketch', 'text',
'code', 'pdf', 'document', 'presentation', 'spreadsheet', 'binary',
]);
function isProjectFileKind(v: unknown): v is ProjectFileKind {
return typeof v === 'string' && VALID_KIND_SET.has(v);
}
type FileSystemEntryWithReader = FileSystemEntry & {
createReader?: () => FileSystemDirectoryReader;
};
type FileSystemFileEntryWithFile = FileSystemFileEntry & {
file: (
successCallback: (file: File) => void,
errorCallback?: (error: DOMException) => void,
) => void;
};
type DataTransferItemWithEntry = DataTransferItem & {
webkitGetAsEntry?: () => FileSystemEntry | null;
};
const MODIFIED_SECTION_ORDER: ModifiedSection[] = [
'today',
'yesterday',
'previous7Days',
'previous30Days',
'older',
];
const MODIFIED_SECTION_LABEL_KEY: Record<ModifiedSection, keyof Dict> = {
today: 'designFiles.modifiedToday',
yesterday: 'designFiles.modifiedYesterday',
previous7Days: 'designFiles.modifiedPrevious7Days',
previous30Days: 'designFiles.modifiedPrevious30Days',
older: 'designFiles.modifiedOlder',
};
function buildActionNotice(message: string, url?: string): ActionNotice {
const trimmedMessage = message.trim();
const trimmedUrl = url?.trim();
if (!trimmedUrl) return { message: trimmedMessage };
const normalizedMessage = trimmedMessage.replace(new RegExp(`\\s*${escapeRegExp(trimmedUrl)}\\s*$`), '');
return { message: normalizedMessage.trim() || trimmedUrl, url: trimmedUrl };
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function ActionNoticeView({ notice }: { notice: ActionNotice | null }) {
if (!notice) return null;
return (
<>
<span>{notice.message}</span>
{notice.url ? (
<>
{' '}
<a href={notice.url} target="_blank" rel="noreferrer">
{notice.url}
</a>
</>
) : null}
</>
);
}
/**
* Full-panel browser for a project's `.od/projects/<id>/` folder. Mirrors
* Claude Design's "Design Files" surface: grouped sections, hover-revealed
* row menu, drop-files footer, and (when a row is selected) a right-side
* preview pane. Triggered as a sticky first tab in FileWorkspace.
*/
export function DesignFilesPanel({
projectId,
files,
liveArtifacts,
onRefreshFiles,
onOpenFile,
onOpenLiveArtifact,
onRenameFile,
onDeleteFile,
onDeleteFiles,
onUpload,
onUploadFiles,
onPaste,
onNewSketch,
uploadError = null,
onClearUploadError,
onPluginFolderAgentAction,
activePluginActionPaths = new Set(),
hiddenPluginActionPaths = new Set(),
}: Props) {
const t = useT();
const analytics = useAnalytics();
const [refreshing, setRefreshing] = useState(false);
const [draggingFiles, setDraggingFiles] = useState(false);
const [dropReadError, setDropReadError] = useState<string | null>(null);
const dragDepthRef = useRef(0);
const [hover, setHover] = useState<string | null>(null);
const [menuPos, setMenuPos] = useState<{ name: string; top: number; left: number } | null>(null);
const MENU_ESTIMATED_HEIGHT = 145;
const MENU_SAFE_PADDING = 8;
const [preview, setPreview] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
// Read once at mount; projectId is stable for this component instance
// (parent uses key={projectId} to remount on project switch).
const savedViewState = useRef(readViewState(projectId));
// Guard for the persist useEffect: skip the initial write so we only
// flush to localStorage when the user actually changes a preference.
// Without this, every project the user opens gets a default-value entry
// written on first render, making stale-key garbage grow unbounded.
// Note: React 18 StrictMode (active in next dev) fires effects twice,
// keeping refs intact across the simulated remount. This means the guard
// fires on the first effect run, sets the ref true, and the second run
// then writes the defaults. The result is a harmless default-value entry
// for the project; subsequent user changes overwrite it correctly. The
// invariant ("no write without a user action") only holds in production
// builds where StrictMode is not active.
const viewStateHasMounted = useRef(false);
const [sortKey, setSortKey] = useState<SortKey>(
() => isSortKey(savedViewState.current.sortKey) ? savedViewState.current.sortKey : DEFAULT_SORT_KEY,
);
const [sortDir, setSortDir] = useState<SortDir>(
() => isSortDir(savedViewState.current.sortDir) ? savedViewState.current.sortDir : DEFAULT_SORT_DIR,
);
const lastKeyPress = useRef<Map<string, number>>(new Map());
const [deleting, setDeleting] = useState(false);
const [installingFolder, setInstallingFolder] = useState<string | null>(null);
const [sharingFolder, setSharingFolder] = useState<string | null>(null);
const [installNotice, setInstallNotice] = useState<ActionNotice | null>(null);
const [groupMode, setGroupMode] = useState<DesignFilesGroupMode>('kind');
const [collapsedModifiedSections, setCollapsedModifiedSections] = useState<
Set<ModifiedSection>
>(new Set());
const [renaming, setRenaming] = useState<{ name: string; draft: string; saving: boolean } | null>(null);
const [dayBoundary, setDayBoundary] = useState(() => Date.now());
const [kindFilter, setKindFilter] = useState<Set<ProjectFileKind>>(() => {
const { kindFilter: kf } = savedViewState.current;
if (!Array.isArray(kf) || kf.length === 0) return new Set();
// Validate each stored value against the current ProjectFileKind union so
// stale values from a prior schema (e.g. a renamed kind) are dropped silently.
return new Set(kf.filter(isProjectFileKind));
});
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement | null>(null);
const [currentDir, setCurrentDir] = useState<string>('');
// Derive immediate subdirectories and files at the current directory level
// from the flat files list. Files with names like "a/b/c.html" contribute
// "a" as a directory when currentDir is '' and "b" when currentDir is "a".
const { dirsAtCurrentDir, filesAtCurrentDir } = useMemo(() => {
const prefix = currentDir === '' ? '' : `${currentDir}/`;
const dirs = new Set<string>();
const localFiles: ProjectFile[] = [];
for (const f of files) {
if (!f.name.startsWith(prefix)) continue;
const remainder = f.name.slice(prefix.length);
const slashIdx = remainder.indexOf('/');
if (slashIdx === -1) {
localFiles.push(f);
} else {
dirs.add(remainder.slice(0, slashIdx));
}
}
return {
dirsAtCurrentDir: [...dirs].sort((a, b) => a.localeCompare(b)),
filesAtCurrentDir: localFiles,
};
}, [files, currentDir]);
const kindCounts = useMemo(() => {
const counts = new Map<ProjectFileKind, number>();
for (const f of filesAtCurrentDir) counts.set(f.kind, (counts.get(f.kind) ?? 0) + 1);
return counts;
}, [filesAtCurrentDir]);
const availableKinds = useMemo(
() =>
Array.from(kindCounts.keys()).sort(
(a, b) => kindSortPriority(a) - kindSortPriority(b),
),
[kindCounts],
);
// Drop any selected-filter kinds that no longer appear in the file list
// (e.g. after a delete leaves the kind empty). Keeps the filter UI honest
// and prevents a stale filter from silently hiding everything.
// Guard: skip when no kinds are available yet — availableKinds is empty only
// when files haven't loaded. Running cleanup against an empty set would
// clear a kindFilter that was correctly restored from localStorage before
// the async file list arrived.
useEffect(() => {
if (availableKinds.length === 0) return;
setKindFilter((prev) => {
if (prev.size === 0) return prev;
const present = new Set(availableKinds);
const next = new Set<ProjectFileKind>();
let changed = false;
for (const k of prev) {
if (present.has(k)) next.add(k);
else changed = true;
}
return changed ? next : prev;
});
}, [availableKinds]);
const filteredFiles = useMemo(() => {
if (kindFilter.size === 0) return filesAtCurrentDir;
return filesAtCurrentDir.filter((f) => kindFilter.has(f.kind));
}, [filesAtCurrentDir, kindFilter]);
const sortedFiles = useMemo(() => {
return [...filteredFiles].sort((a, b) => {
let cmp: number;
if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
else if (sortKey === 'kind') cmp = kindSortPriority(a.kind) - kindSortPriority(b.kind);
else cmp = a.mtime - b.mtime;
return sortDir === 'asc' ? cmp : -cmp;
});
}, [filteredFiles, sortKey, sortDir]);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState<number | 'all'>(
() => isPageSize(savedViewState.current.pageSize) ? savedViewState.current.pageSize : DEFAULT_PAGE_SIZE,
);
const effectivePageSize = pageSize === 'all' ? Math.max(1, sortedFiles.length) : pageSize;
const totalPages = Math.max(1, Math.ceil(sortedFiles.length / effectivePageSize));
const safePage = Math.min(page, totalPages - 1);
const pageFiles = useMemo(
() =>
sortedFiles.slice(
safePage * effectivePageSize,
(safePage + 1) * effectivePageSize,
),
[effectivePageSize, safePage, sortedFiles],
);
const modifiedGroups = useMemo(() => {
const groups: Record<ModifiedSection, ProjectFile[]> = {
today: [],
yesterday: [],
previous7Days: [],
previous30Days: [],
older: [],
};
const thresholds = modifiedSectionThresholds(dayBoundary);
for (const f of pageFiles) {
groups[modifiedSectionFor(f.mtime, thresholds)].push(f);
}
return groups;
}, [dayBoundary, pageFiles]);
const visibleModifiedSections = MODIFIED_SECTION_ORDER.filter(
(section) => modifiedGroups[section].length > 0,
);
const rangeStart = safePage * effectivePageSize + 1;
const rangeEnd = Math.min((safePage + 1) * effectivePageSize, sortedFiles.length);
const allPageSelected = pageFiles.length > 0 && pageFiles.every((f) => selected.has(f.name));
const somePageSelected = !allPageSelected && pageFiles.some((f) => selected.has(f.name));
const hasMultiplePages = totalPages > 1;
const showListControls = sortedFiles.length > 15 || selected.size > 0;
useEffect(() => {
setPage(0);
}, [pageSize]);
// Persist view state so it survives navigation (the panel remounts via
// key={projectId} when the user tabs away and back).
// Skip the initial render: we only want to write when the user actually
// changes a preference, not on every project the user visits.
useEffect(() => {
if (!viewStateHasMounted.current) {
viewStateHasMounted.current = true;
return;
}
writeViewState(projectId, {
sortKey,
sortDir,
pageSize,
kindFilter: Array.from(kindFilter),
});
}, [projectId, sortKey, sortDir, pageSize, kindFilter]);
// Reset to the first page when the filter changes — the previous page
// index may no longer exist (or may now sit past the new totalPages).
useEffect(() => {
setPage(0);
}, [kindFilter]);
// Drop any selected files that fall outside the active filter. Without
// this, bulk delete / download would silently operate on rows the user
// can no longer see — particularly dangerous for destructive deletes.
// We keep the empty-filter branch a no-op so clearing the filter
// doesn't disturb existing selections.
useEffect(() => {
if (kindFilter.size === 0) return;
setSelected((prev) => {
if (prev.size === 0) return prev;
const visible = new Set(filteredFiles.map((f) => f.name));
const next = new Set<string>();
let changed = false;
for (const name of prev) {
if (visible.has(name)) next.add(name);
else changed = true;
}
return changed ? next : prev;
});
}, [filteredFiles, kindFilter]);
// Reset page, selection, and renaming state when the user navigates
// into or out of a directory.
useEffect(() => {
setPage(0);
setSelected(new Set());
setRenaming(null);
}, [currentDir]);
// Navigate up to the nearest ancestor that still exists when files under
// currentDir disappear (e.g. after deleting the last file in a subfolder).
useEffect(() => {
if (currentDir === '') return;
const prefix = `${currentDir}/`;
if (files.some((f) => f.name.startsWith(prefix))) return;
const parts = currentDir.split('/');
for (let i = parts.length - 1; i > 0; i--) {
const ancestor = parts.slice(0, i).join('/');
if (files.some((f) => f.name.startsWith(`${ancestor}/`))) {
setCurrentDir(ancestor);
return;
}
}
setCurrentDir('');
}, [files, currentDir]);
// Outside-click + escape to close the filter popover. Stops short of a
// full focus trap because the popover hosts only checkboxes plus a
// small clear button; the existing tab order through them is fine.
useEffect(() => {
if (!filterMenuOpen) return;
const onMouseDown = (event: MouseEvent) => {
const root = filterMenuRef.current;
if (root && event.target instanceof Node && !root.contains(event.target)) {
setFilterMenuOpen(false);
}
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setFilterMenuOpen(false);
};
window.addEventListener('mousedown', onMouseDown);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('keydown', onKey);
};
}, [filterMenuOpen]);
function toggleKindFilter(kind: ProjectFileKind): void {
setKindFilter((prev) => {
const next = new Set(prev);
if (next.has(kind)) next.delete(kind);
else next.add(kind);
return next;
});
}
useEffect(() => {
if (Number.isFinite(totalPages)) setPage((p) => Math.min(p, totalPages - 1));
}, [totalPages]);
useEffect(() => {
const now = Date.now();
const startOfTomorrow = new Date(now);
startOfTomorrow.setHours(24, 0, 0, 0);
const timer = window.setTimeout(
() => setDayBoundary(Date.now()),
Math.max(1, startOfTomorrow.getTime() - now),
);
return () => window.clearTimeout(timer);
}, [dayBoundary]);
const pluginFolders = useMemo(() => getPluginFolderCandidates(files), [files]);
// Prune selections that no longer exist in the current file list
// (e.g. after a refresh or delete within the same project).
// Cross-project leaks are handled by the parent remounting this
// component via key={projectId}.
useEffect(() => {
setSelected((prev) => {
if (prev.size === 0) return prev;
const names = new Set(files.map((f) => f.name));
const next = new Set(prev);
let changed = false;
for (const n of next) {
if (!names.has(n)) {
next.delete(n);
changed = true;
}
}
return changed ? next : prev;
});
}, [files]);
const previewFile = useMemo(
() => files.find((f) => f.name === preview) ?? null,
[preview, files],
);
useEffect(() => {
if (!menuPos) return;
const close = () => setMenuPos(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
window.addEventListener('mousedown', close);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousedown', close);
window.removeEventListener('keydown', onKey);
};
}, [menuPos]);
async function handleRefresh() {
setRefreshing(true);
try {
await onRefreshFiles();
} finally {
setRefreshing(false);
}
}
function toggleSort(key: SortKey) {
return () => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
};
}
function toggleSelect(name: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
}
function toggleSelectPage() {
setSelected((prev) => {
const next = new Set(prev);
if (allPageSelected) {
for (const f of pageFiles) next.delete(f.name);
} else {
for (const f of pageFiles) next.add(f.name);
}
return next;
});
}
function selectAllFiles() {
setSelected(new Set(sortedFiles.map((f) => f.name)));
}
function clearSelection() {
setSelected(new Set());
}
function openMenuFor(name: string, el: HTMLElement) {
const rect = el.closest('.df-row-menu')?.getBoundingClientRect();
if (!rect) return;
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
let top: number;
if (spaceBelow >= MENU_ESTIMATED_HEIGHT + MENU_SAFE_PADDING) {
top = rect.bottom + 4;
} else if (spaceAbove >= MENU_ESTIMATED_HEIGHT + MENU_SAFE_PADDING) {
top = rect.top - MENU_ESTIMATED_HEIGHT - 4;
} else {
top = Math.max(
MENU_SAFE_PADDING,
viewportHeight - MENU_ESTIMATED_HEIGHT - MENU_SAFE_PADDING,
);
}
const left = Math.max(MENU_SAFE_PADDING, rect.right - 160);
setMenuPos({ name, top, left });
}
function startRename(name: string) {
setMenuPos(null);
setPreview(name);
const draft = currentDir === '' ? name : name.slice(currentDir.length + 1);
setRenaming({ name, draft, saving: false });
}
async function commitRename(name: string, draft: string) {
const nextBasename = draft.trim();
if (!nextBasename) {
setRenaming(null);
return;
}
const nextName = currentDir === '' ? nextBasename : `${currentDir}/${nextBasename}`;
if (nextName === name) {
setRenaming(null);
return;
}
setRenaming({ name, draft, saving: true });
try {
const renamed = await onRenameFile(name, nextName);
if (!renamed) throw new Error('Rename failed');
setPreview((curr) => (curr === name ? renamed.name : curr));
setSelected((prev) => {
if (!prev.has(name)) return prev;
const next = new Set(prev);
next.delete(name);
next.add(renamed.name);
return next;
});
setRenaming(null);
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
setRenaming({ name, draft, saving: false });
}
}
async function handleBatchDelete() {
if (deleting) return;
const fileList = [...selected];
if (fileList.length === 0) return;
setDeleting(true);
try {
await onDeleteFiles(fileList);
// Don't clear `selected` here: confirm-cancel and all-fail paths
// should leave the user's selection intact for retry. The
// `useEffect` above prunes successfully-deleted names automatically
// once `files` refreshes.
} finally {
setDeleting(false);
}
}
function toggleModifiedSection(section: ModifiedSection) {
setCollapsedModifiedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
}
function renderFileRow(f: ProjectFile) {
const active = preview === f.name;
const isHovered = hover === f.name;
const renameState = renaming?.name === f.name ? renaming : null;
return (
<tr
key={f.name}
data-testid={`design-file-row-${f.name}`}
className={`df-file-row ${active ? 'active' : ''} ${selected.has(f.name) ? 'selected' : ''}`}
onMouseEnter={() => setHover(f.name)}
onMouseLeave={() => setHover((c) => (c === f.name ? null : c))}
>
<td className="df-cell-check">
<span
className="df-row-check"
onClick={(e) => {
e.stopPropagation();
toggleSelect(f.name);
}}
role="checkbox"
aria-checked={selected.has(f.name)}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
toggleSelect(f.name);
}
}}
>
{selected.has(f.name) ? '\u2611' : '\u2610'}
</span>
</td>
<td
className="df-cell-icon df-cell-openable"
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
>
<span className="df-row-icon" data-kind={f.kind} aria-hidden>
{kindGlyph(f.kind)}
</span>
</td>
<td
className="df-cell-name df-cell-openable"
onClick={() => {
if (!renameState) setPreview(f.name);
}}
onDoubleClick={() => {
if (!renameState) onOpenFile(f.name);
}}
>
{renameState ? (
<input
autoFocus
className="df-rename-input"
value={renameState.draft}
disabled={renameState.saving}
onChange={(e) => setRenaming({ ...renameState, draft: e.target.value })}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
onBlur={(e) => {
if (e.currentTarget.dataset.skipRenameCommit === '1') return;
void commitRename(f.name, renameState.draft);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.currentTarget.dataset.skipRenameCommit = '1';
void commitRename(f.name, renameState.draft);
} else if (e.key === 'Escape') {
e.preventDefault();
e.currentTarget.dataset.skipRenameCommit = '1';
setRenaming(null);
}
}}
/>
) : (
<button
type="button"
className="df-row-name-btn"
onClick={() => setPreview(f.name)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const now = Date.now();
const last = lastKeyPress.current.get(f.name) ?? 0;
if (now - last < 300) {
lastKeyPress.current.delete(f.name);
onOpenFile(f.name);
} else {
lastKeyPress.current.set(f.name, now);
setPreview(f.name);
}
}
}}
>
<span className="df-row-name-wrap">
<span
className="df-row-name"
title={currentDir === '' ? f.name : f.name.slice(currentDir.length + 1)}
>
{currentDir === '' ? f.name : f.name.slice(currentDir.length + 1)}
</span>
<span className="df-row-sub">{humanBytes(f.size)}</span>
</span>
</button>
)}
</td>
<td
className="df-cell-kind df-cell-openable"
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
>
<span className="df-kind-label">{kindLabel(f.kind, t)}</span>
</td>
<td
className="df-cell-time df-cell-openable"
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
>
{relativeTime(f.mtime, t)}
</td>
<td className="df-cell-menu">
<span
data-testid={`design-file-menu-${f.name}`}
className="df-row-menu"
style={isHovered || active ? { opacity: 1 } : undefined}
role="button"
tabIndex={0}
aria-label={t('designFiles.rowMenu')}
onClick={(e) => {
e.stopPropagation();
openMenuFor(f.name, e.target as HTMLElement);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
openMenuFor(f.name, e.currentTarget as HTMLElement);
}
}}
>
</span>
</td>
</tr>
);
}
function renderDirRow(dirName: string) {
const fullPath = currentDir === '' ? dirName : `${currentDir}/${dirName}`;
const prefix = `${fullPath}/`;
const count = files.filter((f) => f.name.startsWith(prefix)).length;
return (
<tr key={`dir:${fullPath}`} className="df-file-row df-dir-row">
<td className="df-cell-check" />
<td className="df-cell-icon df-cell-openable" onClick={() => setCurrentDir(fullPath)}>
<span className="df-row-icon" data-kind="folder" aria-hidden>
<Icon name="folder" size={14} />
</span>
</td>
<td className="df-cell-name df-cell-openable" onClick={() => setCurrentDir(fullPath)}>
<button type="button" className="df-row-name-btn" onClick={() => setCurrentDir(fullPath)}>
<span className="df-row-name-wrap">
<span className="df-row-name" title={dirName}>{dirName}</span>
<span className="df-row-sub">{t('designFiles.folderCount', { n: count })}</span>
</span>
</button>
</td>
<td className="df-cell-kind df-cell-openable" onClick={() => setCurrentDir(fullPath)}>
<span className="df-kind-label">{t('designFiles.kindFolder')}</span>
</td>
<td className="df-cell-time df-cell-openable" onClick={() => setCurrentDir(fullPath)} />
<td className="df-cell-menu" />
</tr>
);
}
function renderModifiedSections() {
const dirRows = dirsAtCurrentDir.map((d) => renderDirRow(d));
const sectionRows = visibleModifiedSections.flatMap((section) => {
const sectionFiles = modifiedGroups[section];
const collapsed = collapsedModifiedSections.has(section);
const label = t(MODIFIED_SECTION_LABEL_KEY[section]);
return [
<tr className="df-section-row" key={`${section}-label`}>
<td colSpan={6}>
<button
type="button"
className="df-section-toggle"
aria-expanded={!collapsed}
aria-label={`${collapsed ? t('designFiles.expandGroup') : t('designFiles.collapseGroup')} ${label}`}
onClick={() => toggleModifiedSection(section)}
>
<Icon name={collapsed ? 'chevron-right' : 'chevron-down'} size={13} />
<span>{label}</span>
<span className="df-section-count">{sectionFiles.length}</span>
</button>
</td>
</tr>,
...(collapsed ? [] : sectionFiles.map(renderFileRow)),
];
});
return [...dirRows, ...sectionRows];
}
function renderKindSections() {
const dirRows = dirsAtCurrentDir.map((d) => renderDirRow(d));
const grouped = new Map<ProjectFileKind, ProjectFile[]>();
for (const file of pageFiles) {
const next = grouped.get(file.kind) ?? [];
next.push(file);
grouped.set(file.kind, next);
}
const kindRows = [...grouped.entries()]
.sort(([a], [b]) => kindSortPriority(a) - kindSortPriority(b))
.flatMap(([kind, kindFiles]) => [
<tr className="df-section-row" key={`${kind}-label`}>
<td colSpan={6}>
<div className="df-section-label">
<span>{kindLabel(kind, t)}</span>
<span className="df-section-count">{kindFiles.length}</span>
</div>
</td>
</tr>,
...kindFiles.map(renderFileRow),
]);
return [...dirRows, ...kindRows];
}
async function handleBatchDownload() {
const fileList = [...selected];
if (fileList.length === 0) return;
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/archive/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: fileList }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => null);
throw new Error(err?.message || `request failed (${resp.status})`);
}
const blob = await resp.blob();
const header = resp.headers.get('content-disposition') || '';
const star = /filename\*=UTF-8''([^;]+)/i.exec(header);
let filename = 'project.zip';
if (star && star[1]) {
try {
filename = decodeURIComponent(star[1]);
} catch {
filename = star[1];
}
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err) {
console.warn('[batchDownload] failed:', err);
}
}
async function handleDrop(ev: React.DragEvent<HTMLDivElement>) {
ev.preventDefault();
dragDepthRef.current = 0;
setDraggingFiles(false);
setDropReadError(null);
try {
const dropped = await filesFromDataTransfer(ev.dataTransfer);
if (dropped.length > 0) onUploadFiles(dropped);
} catch (error) {
if (!isFileSystemReadError(error)) throw error;
setDropReadError(FILE_SYSTEM_READ_ERROR_MESSAGE);
}
}
async function handlePluginFolderAgentAction(
relativePath: string,
action: PluginFolderAgentAction,
) {
if (!onPluginFolderAgentAction || installingFolder || sharingFolder) return;
setInstallNotice(null);
if (action === 'install') {
setInstallingFolder(relativePath);
} else {
setSharingFolder(`${action}:${relativePath}`);
}
try {
const outcome = await onPluginFolderAgentAction(relativePath, action);
const url = outcome && typeof outcome === 'object' && typeof outcome.url === 'string'
? outcome.url
: '';
const message = outcome && typeof outcome === 'object' && typeof outcome.message === 'string'
? outcome.message
: '';
if (message || url) setInstallNotice(buildActionNotice(message || url, url));
} catch (err) {
setInstallNotice({ message: err instanceof Error ? err.message : String(err) });
} finally {
setInstallingFolder(null);
setSharingFolder(null);
}
}
const refreshControl = (
<button
type="button"
className="icon-only df-refresh-control"
onClick={() => void handleRefresh()}
disabled={refreshing}
title={t('designFiles.refresh')}
aria-label={t('designFiles.refresh')}
>
<Icon name={refreshing ? 'spinner' : 'reload'} size={14} />
</button>
);
const fileActions =
selected.size > 0 ? (
<div className="df-actions">
<button
type="button"
onClick={() => {
trackFileManagerClick(analytics.track, {
page_name: 'file_manager',
area: 'file_manager',
element: 'download_as_zip',
});
void handleBatchDownload();
}}
title={t('designFiles.downloadSelected', { n: selected.size })}
>
<Icon name="download" size={13} />
<span>{t('designFiles.downloadSelected', { n: selected.size })}</span>
</button>
<button
type="button"
className="danger"
data-testid="design-files-batch-delete"
disabled={deleting}
onClick={() => void handleBatchDelete()}
title={t('designFiles.deleteSelected', { n: selected.size })}
>
<span>{t('designFiles.deleteSelected', { n: selected.size })}</span>
</button>
</div>
) : (
<div className="df-actions">
<button type="button" onClick={onNewSketch} title={t('designFiles.newSketch')}>
<Icon name="pencil" size={13} />
<span>{t('designFiles.newSketch')}</span>
</button>
<button type="button" onClick={onPaste} title={t('designFiles.paste.title')}>
<Icon name="copy" size={13} />
<span>{t('designFiles.paste.label')}</span>
</button>
<button
type="button"
data-testid="design-files-upload-trigger"
onClick={onUpload}
title={t('designFiles.upload.title')}
>
<Icon name="upload" size={13} />
<span>{t('designFiles.upload.label')}</span>
</button>
</div>
);
const groupToggle =
files.length > 0 ? (
<div
className="df-group-toggle"
role="group"
aria-label={t('designFiles.groupBy')}
>
<span>{t('designFiles.groupBy')}</span>
<button
type="button"
className={groupMode === 'kind' ? 'active' : ''}
aria-pressed={groupMode === 'kind'}
onClick={() => setGroupMode('kind')}
>
{t('designFiles.groupByKind')}
</button>
<button
type="button"
className={groupMode === 'modified' ? 'active' : ''}
aria-pressed={groupMode === 'modified'}
onClick={() => setGroupMode('modified')}
>
{t('designFiles.groupByModified')}
</button>
</div>
) : (
<span className="df-controls-spacer" aria-hidden="true" />
);
const kindFilterControl =
files.length > 0 && availableKinds.length > 1 ? (
<div className="df-kind-filter" ref={filterMenuRef}>
<button
type="button"
className={`df-kind-filter-trigger${kindFilter.size > 0 ? ' active' : ''}`}
aria-haspopup="dialog"
aria-expanded={filterMenuOpen}
aria-label={t('designFiles.filterBy')}
onClick={() => setFilterMenuOpen((open) => !open)}
>
<Icon name="sliders" size={13} />
<span className="df-kind-filter-trigger-label">
{kindFilter.size === 0
? t('designFiles.filterBy')
: kindFilter.size === 1
? kindLabel(Array.from(kindFilter)[0]!, t)
: t('designFiles.filterCount', { n: kindFilter.size })}
</span>
{kindFilter.size > 0 ? (
<span
className="df-kind-filter-count"
aria-hidden
>
{kindFilter.size}
</span>
) : null}
</button>
{filterMenuOpen ? (
<div
className="df-kind-filter-popover"
role="dialog"
aria-label={t('designFiles.filterBy')}
>
<div className="df-kind-filter-header">
<span>{t('designFiles.filterBy')}</span>
{kindFilter.size > 0 ? (
<button
type="button"
className="df-kind-filter-clear"
onClick={() => setKindFilter(new Set())}
>
{t('designFiles.filterClear')}
</button>
) : null}
</div>
<ul className="df-kind-filter-list">
{availableKinds.map((kind) => {
const checked = kindFilter.has(kind);
const count = kindCounts.get(kind) ?? 0;
return (
<li key={kind}>
<label className="df-kind-filter-item">
<input
type="checkbox"
checked={checked}
onChange={() => toggleKindFilter(kind)}
/>
<span className="df-kind-filter-glyph" aria-hidden>
{kindGlyph(kind)}
</span>
<span className="df-kind-filter-label">
{kindLabel(kind, t)}
</span>
<span className="df-kind-filter-itemcount">
{count}
</span>
</label>
</li>
);
})}
</ul>
</div>
) : null}
</div>
) : null;
const visibleUploadError = uploadError ?? dropReadError;
return (
<div className={`df-panel ${preview ? '' : 'no-preview'}`}>
<div className="df-main">
<div className="df-body">
{visibleUploadError && !preview ? (
<div className="df-upload-banner" data-testid="upload-error-banner">
<span>{visibleUploadError}</span>
{onClearUploadError || dropReadError ? (
<button
type="button"
data-testid="upload-error-dismiss"
onClick={() => {
setDropReadError(null);
onClearUploadError?.();
}}
>
Dismiss
</button>
) : null}
</div>
) : null}
<div className="df-controls-row">
{refreshControl}
{groupToggle}
{kindFilterControl}
{fileActions}
</div>
{currentDir !== '' ? (
<nav className="df-breadcrumbs" aria-label={t('designFiles.crumbs')}>
<button
type="button"
className="df-breadcrumb-btn"
onClick={() => setCurrentDir('')}
>
{t('designFiles.crumbs')}
</button>
{currentDir.split('/').map((segment, idx, parts) => {
const path = parts.slice(0, idx + 1).join('/');
const isLast = idx === parts.length - 1;
return (
<span key={path} className="df-breadcrumb-segment">
<span className="df-breadcrumb-sep" aria-hidden>/</span>
{isLast ? (
<span className="df-breadcrumb-current">{segment}</span>
) : (
<button
type="button"
className="df-breadcrumb-btn"
onClick={() => setCurrentDir(path)}
>
{segment}
</button>
)}
</span>
);
})}
</nav>
) : null}
{files.length === 0 && liveArtifacts.length === 0 ? (
<div className="df-empty" data-testid="design-files-empty">
<div className="df-empty-pill">
<span className="df-empty-title">
{t('designFiles.empty')}
</span>
<button
type="button"
className="df-empty-cta"
data-testid="design-files-empty-new-sketch"
onClick={onNewSketch}
title={t('designFiles.newSketch')}
>
<Icon name="pencil" size={13} />
<span>{t('designFiles.newSketch')}</span>
</button>
</div>
</div>
) : (
<>
{liveArtifacts.length > 0 ? (
<div className="df-section" key="live-artifacts">
<div className="df-section-label">{t('designFiles.sectionLiveArtifacts')}</div>
{liveArtifacts.map((artifact) => (
<button
key={artifact.artifactId}
type="button"
data-testid={`design-file-row-${artifact.tabId}`}
className="df-row df-row-live-artifact"
onDoubleClick={() => onOpenLiveArtifact(artifact.tabId)}
onClick={() => onOpenLiveArtifact(artifact.tabId)}
>
<span className="df-row-icon" data-kind="live-artifact" aria-hidden>
</span>
<span className="df-row-name-wrap">
<span className="df-row-name" title={artifact.title}>
{artifact.title}
</span>
<span className="df-row-sub">
<span>{t('designFiles.kindLiveArtifact')}</span>
<LiveArtifactBadges
compact
status={artifact.status}
refreshStatus={artifact.refreshStatus}
/>
</span>
</span>
<span className="df-row-time">
{relativeTime(Date.parse(artifact.updatedAt) || Date.now(), t)}
</span>
</button>
))}
</div>
) : null}
{pluginFolders.length > 0 ? (
<div className="df-section" key="plugin-folders">
<div className="df-section-label">
Plugin folders
<span className="df-section-count">{pluginFolders.length}</span>
</div>
{installNotice ? (
<div className="df-inline-notice" role="status">
<ActionNoticeView notice={installNotice} />
</div>
) : null}
{pluginFolders.filter((folder) => !hiddenPluginActionPaths.has(folder.path)).map((folder) => {
const actionBusy = activePluginActionPaths.has(folder.path);
return (
<div
key={folder.path}
className="df-row df-row-plugin-folder"
data-testid={`design-plugin-folder-${folder.path}`}
>
<button
type="button"
className="df-row-folder-main"
onClick={() => setPreview(folder.manifestPath)}
>
<span className="df-row-icon" data-kind="folder" aria-hidden>
DIR
</span>
<span className="df-row-name-wrap">
<span className="df-row-name">{folder.path}</span>
<span className="df-row-sub">
{folder.fileCount} files · ready to add to My plugins
</span>
</span>
</button>
<span className="df-row-time">{relativeTime(folder.updatedAt, t)}</span>
{onPluginFolderAgentAction ? (
<div className="df-plugin-actions">
<button
type="button"
className="df-plugin-install"
data-testid={`design-plugin-folder-install-${folder.path}`}
disabled={actionBusy || installingFolder !== null || sharingFolder !== null}
onClick={() =>
void handlePluginFolderAgentAction(folder.path, 'install')
}
>
{installingFolder === folder.path ? 'Sending…' : 'Add to My plugins'}
</button>
<button
type="button"
className="df-plugin-install"
data-testid={`design-plugin-folder-publish-${folder.path}`}
disabled={actionBusy || installingFolder !== null || sharingFolder !== null}
onClick={() =>
void handlePluginFolderAgentAction(folder.path, 'publish')
}
>
{sharingFolder === `publish:${folder.path}` ? 'Sending…' : 'Publish repo'}
</button>
<button
type="button"
className="df-plugin-install"
data-testid={`design-plugin-folder-contribute-${folder.path}`}
disabled={actionBusy || installingFolder !== null || sharingFolder !== null}
onClick={() =>
void handlePluginFolderAgentAction(folder.path, 'contribute')
}
>
{sharingFolder === `contribute:${folder.path}` ? 'Sending…' : 'Open Design PR'}
</button>
</div>
) : null}
</div>
)})}
</div>
) : null}
{(sortedFiles.length > 0 || dirsAtCurrentDir.length > 0) ? (
<>
{showListControls ? (
<div className="df-pagination df-pagination-start">
<label>
{t('designFiles.perPage')}:
<select
data-testid="df-page-size-select"
value={pageSize === 'all' ? 'all' : pageSize}
onChange={(e) => {
const val = e.target.value;
setPageSize(val === 'all' ? 'all' : Number(val));
}}
>
<option value={15}>15</option>
<option value={30}>30</option>
<option value={45}>45</option>
<option value={60}>60</option>
<option value="all">{t('designFiles.all')}</option>
</select>
</label>
{!hasMultiplePages ? (
<span className="df-page-info">
{t('designFiles.pageInfo', { start: rangeStart, end: rangeEnd, total: sortedFiles.length })}
</span>
) : null}
<div className="df-select-bar">
{selected.size < sortedFiles.length ? (
<button type="button" className="df-select-all" onClick={selectAllFiles}>
{t('designFiles.selectAll', { n: sortedFiles.length })}
</button>
) : null}
{selected.size > 0 ? (
<button type="button" className="df-select-all" onClick={clearSelection}>
{t('designFiles.clearSelection')}
</button>
) : null}
</div>
</div>
) : null}
<table className="df-table">
<thead>
<tr>
<th className="df-th-check">
<span
className="df-row-check"
onClick={toggleSelectPage}
role="checkbox"
aria-checked={allPageSelected}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSelectPage();
}
}}
ref={(el) => {
if (el) (el as HTMLElement).ariaChecked = allPageSelected ? 'true' : somePageSelected ? 'mixed' : 'false';
}}
>
{allPageSelected ? '\u2611' : somePageSelected ? '\u25A3' : '\u2610'}
</span>
</th>
<th className="df-th-icon" />
<th
className="df-th-name df-th-sortable"
aria-sort={sortKey === 'name' ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
>
<button type="button" className="df-th-btn" onClick={toggleSort('name')}>
{t('designFiles.colName')}
{sortKey === 'name' ? <span className="df-sort-arrow">{sortDir === 'asc' ? ' \u2191' : ' \u2193'}</span> : null}
</button>
</th>
<th
className="df-th-kind df-th-sortable"
aria-sort={sortKey === 'kind' ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
>
<button type="button" className="df-th-btn" onClick={toggleSort('kind')}>
{t('designFiles.colKind')}
{sortKey === 'kind' ? <span className="df-sort-arrow">{sortDir === 'asc' ? ' \u2191' : ' \u2193'}</span> : null}
</button>
</th>
<th
className="df-th-time df-th-sortable"
aria-sort={sortKey === 'mtime' ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
>
<button type="button" className="df-th-btn" onClick={toggleSort('mtime')}>
{t('designFiles.colModified')}
{sortKey === 'mtime' ? <span className="df-sort-arrow">{sortDir === 'asc' ? ' \u2191' : ' \u2193'}</span> : null}
</button>
</th>
<th className="df-th-menu" />
</tr>
</thead>
<tbody>
{groupMode === 'modified'
? renderModifiedSections()
: groupMode === 'kind'
? renderKindSections()
: [...dirsAtCurrentDir.map(renderDirRow), ...pageFiles.map(renderFileRow)]}
</tbody>
</table>
{hasMultiplePages ? (
<div className="df-pagination df-pagination-center">
<button
type="button"
className="df-page-btn"
disabled={safePage <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
{t('designFiles.prev')}
</button>
<label>
{t('designFiles.jumpToPage')}:
<select
value={safePage}
onChange={(e) => setPage(Number(e.target.value))}
>
{Array.from({ length: totalPages }, (_, i) => (
<option key={i} value={i}>
{i + 1}
</option>
))}
</select>
</label>
<button
type="button"
className="df-page-btn"
disabled={safePage >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
{t('designFiles.next')}
</button>
<span className="df-page-info">
{t('designFiles.pageInfo', { start: rangeStart, end: rangeEnd, total: sortedFiles.length })}
</span>
</div>
) : null}
</>
) : null}
</>
)}
<div
className={`df-drop ${draggingFiles ? 'dragging' : ''}`}
onDragEnter={(ev) => {
ev.preventDefault();
dragDepthRef.current += 1;
setDraggingFiles(true);
}}
onDragOver={(ev) => {
ev.preventDefault();
ev.dataTransfer.dropEffect = 'copy';
}}
onDragLeave={(ev) => {
if (!ev.currentTarget.contains(ev.relatedTarget as Node | null)) {
dragDepthRef.current = 0;
setDraggingFiles(false);
return;
}
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setDraggingFiles(false);
}}
onDrop={handleDrop}
>
<span className="label">{t('designFiles.dropTitle')}</span>
<span className="desc">{t('designFiles.dropDesc')}</span>
</div>
</div>
</div>
{preview && previewFile ? (
// Key on the file name so React unmounts the previous DfPreview
// (and its iframe / image element) when the user clicks a
// different file. Without this, React diffing reuses the same
// iframe DOM node and the browser keeps showing the first
// file's contents — only the `src` prop changes but the iframe
// never actually navigates.
<DfPreview
key={previewFile.name}
projectId={projectId}
file={previewFile}
onOpen={() => onOpenFile(previewFile.name)}
onClose={() => setPreview(null)}
/>
) : null}
{menuPos ? (
<div
data-testid="design-file-menu-popover"
className="df-row-popover"
style={{ top: menuPos.top, left: menuPos.left }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const name = menuPos.name;
setMenuPos(null);
onOpenFile(name);
}}
>
{t('designFiles.openInTab')}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
startRename(menuPos.name);
}}
>
{t('common.rename')}
</button>
<a
href={projectFileUrl(projectId, menuPos.name)}
download={menuPos.name}
style={{ textDecoration: 'none' }}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMenuPos(null);
}}
>
{t('designFiles.download')}
</button>
</a>
<button
type="button"
className="danger"
data-testid={`design-file-delete-${menuPos.name}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
const name = menuPos.name;
setMenuPos(null);
onDeleteFile(name);
}}
>
{t('designFiles.delete')}
</button>
</div>
) : null}
</div>
);
}
function DfPreview({
projectId,
file,
onOpen,
onClose,
}: {
projectId: string;
file: ProjectFile;
onOpen: () => void;
onClose: () => void;
}) {
const t = useT();
const url = projectFileUrl(projectId, file.name);
const rendersSketchJson = isRenderableSketchJson(file);
const openPreviewLabel = `${t('designFiles.previewOpen')} ${file.name}`;
const thumbCanOpen = file.kind !== 'audio' && file.kind !== 'video';
return (
<aside className="df-preview">
<button
type="button"
className="df-preview-close"
onClick={onClose}
title={t('designFiles.previewClose')}
aria-label={t('designFiles.previewClose')}
>
<Icon name="close" size={13} />
</button>
<div className={`df-preview-thumb${thumbCanOpen ? ' is-openable' : ''}`}>
{rendersSketchJson ? (
<SketchPreview projectId={projectId} file={file} />
) : file.kind === 'image' || file.kind === 'sketch' ? (
<img src={`${url}?v=${Math.round(file.mtime)}`} alt={file.name} />
) : file.kind === 'html' ? (
<HtmlPreviewThumbnail projectId={projectId} file={file} />
) : file.kind === 'video' ? (
<video
src={`${url}?v=${Math.round(file.mtime)}`}
controls
playsInline
preload="metadata"
/>
) : file.kind === 'audio' ? (
<audio src={`${url}?v=${Math.round(file.mtime)}`} controls preload="metadata" />
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-faint)',
fontSize: 38,
}}
>
{kindGlyph(file.kind)}
</div>
)}
{thumbCanOpen ? (
<button
type="button"
className="df-preview-thumb-open"
onClick={onOpen}
title={openPreviewLabel}
aria-label={openPreviewLabel}
/>
) : null}
</div>
<div className="df-preview-meta" data-testid="design-file-preview">
<div className="df-preview-actions">
<Button variant="ghost" onClick={onOpen}>
<Icon name="eye" size={13} />
<span>{t('designFiles.previewOpen')}</span>
</Button>
<a
className="ghost-link"
href={url}
download={file.name}
>
<Icon name="download" size={13} />
<span>{t('designFiles.download')}</span>
</a>
</div>
<div className="df-preview-name">{file.name}</div>
<div className="df-preview-kind">{kindLabel(file.kind, t)}</div>
<div className="df-preview-stats">
{t('designFiles.modified', {
time: relativeTime(file.mtime, t),
size: humanBytes(file.size),
})}
</div>
</div>
</aside>
);
}
function HtmlPreviewThumbnail({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const url = projectFileUrl(projectId, file.name);
const [srcDoc, setSrcDoc] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void fetch(`${url}?v=${Math.round(file.mtime)}`)
.then((response) => (response.ok ? response.text() : null))
.then((html) => {
if (cancelled || html === null) return;
setSrcDoc(buildSrcdoc(html, { baseHref: projectRawUrl(projectId, baseDirForFile(file.name)) }));
})
.catch(() => {
if (!cancelled) setSrcDoc(null);
});
return () => {
cancelled = true;
};
}, [file.mtime, file.name, projectId, url]);
return (
<iframe
title={file.name}
src={srcDoc ? undefined : url}
srcDoc={srcDoc ?? undefined}
sandbox="allow-scripts allow-downloads"
/>
);
}
function baseDirForFile(name: string): string {
const index = name.lastIndexOf('/');
return index >= 0 ? name.slice(0, index + 1) : '';
}
function kindSortPriority(kind: ProjectFileKind): number {
if (kind === 'html') return 0;
if (kind === 'text') return 1;
if (kind === 'code') return 2;
if (kind === 'sketch') return 3;
if (kind === 'image') return 4;
if (kind === 'document') return 5;
if (kind === 'pdf') return 6;
if (kind === 'presentation') return 7;
if (kind === 'spreadsheet') return 8;
if (kind === 'video') return 9;
if (kind === 'audio') return 10;
return 11;
}
interface ModifiedSectionThresholds {
todayStart: number;
yesterdayStart: number;
previous7DaysStart: number;
previous30DaysStart: number;
}
function modifiedSectionThresholds(now: number): ModifiedSectionThresholds {
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
return {
todayStart: startOfToday.getTime(),
yesterdayStart: dateDaysBefore(startOfToday, 1).getTime(),
previous7DaysStart: dateDaysBefore(startOfToday, 7).getTime(),
previous30DaysStart: dateDaysBefore(startOfToday, 30).getTime(),
};
}
function modifiedSectionFor(ts: number, thresholds: ModifiedSectionThresholds): ModifiedSection {
const { todayStart, yesterdayStart, previous7DaysStart, previous30DaysStart } = thresholds;
if (ts >= todayStart) return 'today';
if (ts >= yesterdayStart) return 'yesterday';
if (ts >= previous7DaysStart) return 'previous7Days';
if (ts >= previous30DaysStart) return 'previous30Days';
return 'older';
}
function dateDaysBefore(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() - days);
return result;
}
async function filesFromDataTransfer(dataTransfer: DataTransfer): Promise<File[]> {
const items = Array.from(dataTransfer.items ?? []);
const fallbackFiles = Array.from(dataTransfer.files ?? []);
if (items.length === 0) return fallbackFiles;
const results = await Promise.allSettled(items.map(filesFromDataTransferItem));
const rejected = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
if (rejected) {
if (fallbackFiles.length > 0) return fallbackFiles;
throw rejected.reason;
}
const files = results.flatMap((result) => (result.status === 'fulfilled' ? result.value : []));
return files.length > 0 ? files : fallbackFiles;
}
async function filesFromDataTransferItem(item: DataTransferItem): Promise<File[]> {
const entry = (item as DataTransferItemWithEntry).webkitGetAsEntry?.();
if (!entry) {
const file = item.kind === 'file' ? item.getAsFile() : null;
return file ? [file] : [];
}
return filesFromFileSystemEntry(entry);
}
async function filesFromFileSystemEntry(entry: FileSystemEntry): Promise<File[]> {
if (entry.isFile) return [await fileFromEntry(entry as FileSystemFileEntryWithFile)];
if (!entry.isDirectory) return [];
const reader = (entry as FileSystemEntryWithReader).createReader?.();
if (!reader) return [];
const files: File[] = [];
for (;;) {
const entries = await readEntryBatch(reader);
if (entries.length === 0) break;
const nested = await Promise.all(entries.map(filesFromFileSystemEntry));
files.push(...nested.flat());
}
return files;
}
function fileFromEntry(entry: FileSystemFileEntryWithFile): Promise<File> {
return new Promise((resolve, reject) => {
entry.file(resolve, (error) => {
reject(createFileSystemReadError('Could not read dropped file', error));
});
});
}
function readEntryBatch(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
return new Promise((resolve, reject) => {
reader.readEntries(resolve, (error) => {
reject(createFileSystemReadError('Could not read dropped folder', error));
});
});
}
function kindGlyph(kind: ProjectFileKind): string {
if (kind === 'html') return '\u27E8\u27E9';
if (kind === 'image') return '\u25A3';
if (kind === 'sketch') return '\u270E';
if (kind === 'text') return '\u00B6';
if (kind === 'code') return '\u007B\u007D';
if (kind === 'pdf') return 'PDF';
if (kind === 'document') return 'DOC';
if (kind === 'presentation') return 'PPT';
if (kind === 'spreadsheet') return 'XLS';
return '\u00B7';
}
function kindLabel(kind: ProjectFileKind, t: TranslateFn): string {
if (kind === 'html') return t('designFiles.kindHtml');
if (kind === 'image') return t('designFiles.kindImage');
if (kind === 'sketch') return t('designFiles.kindSketch');
if (kind === 'text') return t('designFiles.kindText');
if (kind === 'code') return t('designFiles.kindCode');
if (kind === 'pdf') return t('designFiles.kindPdf');
if (kind === 'document') return t('designFiles.kindDocument');
if (kind === 'presentation') return t('designFiles.kindPresentation');
if (kind === 'spreadsheet') return t('designFiles.kindSpreadsheet');
return t('designFiles.kindBinary');
}
function relativeTime(ts: number, t: TranslateFn): string {
const diff = Date.now() - ts;
const min = 60_000;
const hr = 60 * min;
const day = 24 * hr;
if (diff < min) return t('common.justNow');
if (diff < hr) return t('common.minutesAgo', { n: Math.floor(diff / min) });
if (diff < day) return t('common.hoursAgo', { n: Math.floor(diff / hr) });
if (diff < 7 * day) return t('common.daysAgo', { n: Math.floor(diff / day) });
if (diff < 30 * day)
return t('designFiles.weeksAgo', { n: Math.floor(diff / (7 * day)) });
return new Date(ts).toLocaleDateString();
}
function humanBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}