a98096a042
- Created .gitignore to exclude build artifacts and dependencies. - Added index.html as the main entry point for the application. - Included LICENSE file with Apache 2.0 terms. - Initialized package.json and package-lock.json for project dependencies. - Added pnpm-lock.yaml for package management. - Created QUICKSTART.md for setup instructions. - Added README.md and README.zh-CN.md for project documentation in English and Chinese.
464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useT } from '../i18n';
|
|
import {
|
|
deleteProjectFile,
|
|
fetchProjectFileText,
|
|
uploadProjectFile,
|
|
writeProjectTextFile,
|
|
} from '../providers/registry';
|
|
import type { OpenTabsState, ProjectFile } from '../types';
|
|
import { DesignFilesPanel } from './DesignFilesPanel';
|
|
import { FileViewer } from './FileViewer';
|
|
import { Icon } from './Icon';
|
|
import { PasteTextDialog } from './PasteTextDialog';
|
|
import { SketchEditor, type SketchDocument, type SketchItem } from './SketchEditor';
|
|
|
|
interface Props {
|
|
projectId: string;
|
|
files: ProjectFile[];
|
|
onRefreshFiles: () => Promise<void> | void;
|
|
isDeck: boolean;
|
|
onExportAsPptx?: ((fileName: string) => void) | undefined;
|
|
streaming?: boolean;
|
|
openRequest?: { name: string; nonce: number } | null;
|
|
// Persisted set of open tabs + active tab. Owned by ProjectView so the
|
|
// daemon's SQLite store can hold the source of truth and survive reloads.
|
|
tabsState: OpenTabsState;
|
|
onTabsStateChange: (next: OpenTabsState) => void;
|
|
}
|
|
|
|
interface SketchState {
|
|
items: SketchItem[];
|
|
dirty: boolean;
|
|
persisted: boolean;
|
|
loaded: boolean;
|
|
saving: boolean;
|
|
}
|
|
|
|
const DESIGN_FILES_TAB = '__design_files__';
|
|
|
|
export function FileWorkspace({
|
|
projectId,
|
|
files,
|
|
onRefreshFiles,
|
|
isDeck,
|
|
onExportAsPptx,
|
|
streaming,
|
|
openRequest,
|
|
tabsState,
|
|
onTabsStateChange,
|
|
}: Props) {
|
|
const t = useT();
|
|
// Persisted tabs come from the parent. Active tab can transiently point
|
|
// at a pending sketch — pending sketches are not in tabsState.tabs.
|
|
const persistedTabs = tabsState.tabs;
|
|
const [activeTab, setActiveTab] = useState<string>(
|
|
tabsState.active ?? DESIGN_FILES_TAB,
|
|
);
|
|
|
|
const [showPasteDialog, setShowPasteDialog] = useState(false);
|
|
const [sketches, setSketches] = useState<Record<string, SketchState>>({});
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
// Pull the persisted active tab in when the parent's hydration completes
|
|
// (or on project switch). Fall back to the Design Files browser so a
|
|
// fresh project lands in a useful place.
|
|
useEffect(() => {
|
|
setActiveTab(tabsState.active ?? DESIGN_FILES_TAB);
|
|
}, [tabsState.active]);
|
|
|
|
function setPersistedActive(name: string | null) {
|
|
setActiveTab(name ?? DESIGN_FILES_TAB);
|
|
onTabsStateChange({ tabs: persistedTabs, active: name });
|
|
}
|
|
|
|
function activatePending(name: string) {
|
|
// Pending sketches are not in tabsState.tabs — flip the local
|
|
// activeTab without round-tripping through the parent.
|
|
setActiveTab(name);
|
|
}
|
|
|
|
// When the persisted tab list changes and the active tab is gone, fall
|
|
// back to the last remaining tab. Skip transient activeTab values
|
|
// (DESIGN_FILES_TAB, pending sketches) since those aren't in persistedTabs.
|
|
useEffect(() => {
|
|
if (activeTab === DESIGN_FILES_TAB) return;
|
|
if (sketches[activeTab] && !sketches[activeTab]!.persisted) return;
|
|
if (!persistedTabs.includes(activeTab)) {
|
|
setPersistedActive(persistedTabs[persistedTabs.length - 1] ?? null);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [persistedTabs, activeTab]);
|
|
|
|
// External open requests from chat (tool cards, produced-file chips,
|
|
// deep-linked URL, or the parent's auto-open after an agent Write) —
|
|
// add the file to the open-tabs set and focus it.
|
|
useEffect(() => {
|
|
if (!openRequest) return;
|
|
const name = openRequest.name;
|
|
if (!name) return;
|
|
onTabsStateChange({
|
|
tabs: persistedTabs.includes(name) ? persistedTabs : [...persistedTabs, name],
|
|
active: name,
|
|
});
|
|
setActiveTab(name);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [openRequest]);
|
|
|
|
function openFile(name: string) {
|
|
onTabsStateChange({
|
|
tabs: persistedTabs.includes(name) ? persistedTabs : [...persistedTabs, name],
|
|
active: name,
|
|
});
|
|
setActiveTab(name);
|
|
}
|
|
|
|
function closeTab(name: string) {
|
|
const isPending = sketches[name] && !sketches[name]!.persisted;
|
|
if (isPending) {
|
|
setSketches((curr) => {
|
|
const next = { ...curr };
|
|
delete next[name];
|
|
return next;
|
|
});
|
|
if (activeTab === name) {
|
|
setPersistedActive(persistedTabs[persistedTabs.length - 1] ?? null);
|
|
}
|
|
return;
|
|
}
|
|
const nextTabs = persistedTabs.filter((n) => n !== name);
|
|
const nextActive =
|
|
tabsState.active === name
|
|
? nextTabs[nextTabs.length - 1] ?? null
|
|
: tabsState.active;
|
|
onTabsStateChange({ tabs: nextTabs, active: nextActive });
|
|
setActiveTab(nextActive ?? DESIGN_FILES_TAB);
|
|
setSketches((curr) => {
|
|
const next = { ...curr };
|
|
const entry = next[name];
|
|
if (entry && !entry.persisted) delete next[name];
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function handleFilePicked(ev: React.ChangeEvent<HTMLInputElement>) {
|
|
const f = ev.target.files?.[0];
|
|
if (!f) return;
|
|
const result = await uploadProjectFile(projectId, f);
|
|
ev.target.value = '';
|
|
if (result) {
|
|
await onRefreshFiles();
|
|
openFile(result.name);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(name: string) {
|
|
if (!confirm(t('workspace.deleteFileConfirm', { name }))) return;
|
|
const ok = await deleteProjectFile(projectId, name);
|
|
if (ok) {
|
|
await onRefreshFiles();
|
|
const nextTabs = persistedTabs.filter((n) => n !== name);
|
|
const nextActive =
|
|
tabsState.active === name
|
|
? nextTabs[nextTabs.length - 1] ?? null
|
|
: tabsState.active;
|
|
onTabsStateChange({ tabs: nextTabs, active: nextActive });
|
|
setActiveTab(nextActive ?? DESIGN_FILES_TAB);
|
|
setSketches((curr) => {
|
|
const next = { ...curr };
|
|
delete next[name];
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
|
|
function startNewSketch() {
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
const name = `sketch-${stamp}.sketch.json`;
|
|
setSketches((curr) => ({
|
|
...curr,
|
|
[name]: { items: [], dirty: false, persisted: false, loaded: true, saving: false },
|
|
}));
|
|
activatePending(name);
|
|
}
|
|
|
|
// When the active tab is a sketch we don't have items for yet, load from
|
|
// disk. Pending sketches start with loaded=true and skip this path.
|
|
useEffect(() => {
|
|
if (activeTab === DESIGN_FILES_TAB) return;
|
|
if (!isSketchName(activeTab)) return;
|
|
if (sketches[activeTab]?.loaded) return;
|
|
let cancelled = false;
|
|
void fetchProjectFileText(projectId, activeTab).then((text) => {
|
|
if (cancelled) return;
|
|
const items = parseSketchDocument(text);
|
|
setSketches((curr) => ({
|
|
...curr,
|
|
[activeTab]: {
|
|
items,
|
|
dirty: false,
|
|
persisted: true,
|
|
loaded: true,
|
|
saving: false,
|
|
},
|
|
}));
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [activeTab, projectId, sketches]);
|
|
|
|
function setSketchItems(name: string, items: SketchItem[]) {
|
|
setSketches((curr) => ({
|
|
...curr,
|
|
[name]: {
|
|
...(curr[name] ?? { persisted: false, loaded: true, saving: false }),
|
|
items,
|
|
dirty: true,
|
|
} as SketchState,
|
|
}));
|
|
}
|
|
|
|
async function saveSketch(name: string) {
|
|
const entry = sketches[name];
|
|
if (!entry) return;
|
|
setSketches((curr) => ({ ...curr, [name]: { ...curr[name]!, saving: true } }));
|
|
const doc: SketchDocument = { version: 1, items: entry.items };
|
|
const file = await writeProjectTextFile(projectId, name, JSON.stringify(doc, null, 2));
|
|
if (file) {
|
|
setSketches((curr) => ({
|
|
...curr,
|
|
[name]: { ...curr[name]!, dirty: false, persisted: true, saving: false },
|
|
}));
|
|
// Promote the previously-pending sketch into the persisted tab list.
|
|
onTabsStateChange({
|
|
tabs: persistedTabs.includes(name) ? persistedTabs : [...persistedTabs, name],
|
|
active: name,
|
|
});
|
|
setActiveTab(name);
|
|
await onRefreshFiles();
|
|
} else {
|
|
setSketches((curr) => ({ ...curr, [name]: { ...curr[name]!, saving: false } }));
|
|
}
|
|
}
|
|
|
|
const activeFile = useMemo<ProjectFile | null>(() => {
|
|
if (activeTab === DESIGN_FILES_TAB) return null;
|
|
const onDisk = files.find((f) => f.name === activeTab);
|
|
if (onDisk) return onDisk;
|
|
if (isSketchName(activeTab) && sketches[activeTab]) {
|
|
return {
|
|
name: activeTab,
|
|
size: 0,
|
|
mtime: Date.now(),
|
|
kind: 'sketch',
|
|
mime: 'application/json',
|
|
};
|
|
}
|
|
return null;
|
|
}, [activeTab, files, sketches]);
|
|
|
|
// Tabs rendered are persisted tabs plus any pending (un-saved) sketches.
|
|
const tabNames = useMemo(() => {
|
|
const seen = new Set(persistedTabs);
|
|
const extras: string[] = [];
|
|
for (const name of Object.keys(sketches)) {
|
|
if (!sketches[name]?.persisted && !seen.has(name)) {
|
|
extras.push(name);
|
|
seen.add(name);
|
|
}
|
|
}
|
|
return [...persistedTabs, ...extras];
|
|
}, [persistedTabs, sketches]);
|
|
|
|
const isActiveSketch = activeFile?.kind === 'sketch' && isSketchName(activeFile.name);
|
|
const activeSketch = activeFile && isActiveSketch ? sketches[activeFile.name] : null;
|
|
|
|
return (
|
|
<div className="workspace">
|
|
<div className="ws-tabs-bar">
|
|
<button
|
|
type="button"
|
|
className={`ws-tab design-files-tab ${activeTab === DESIGN_FILES_TAB ? 'active' : ''}`}
|
|
onClick={() => setActiveTab(DESIGN_FILES_TAB)}
|
|
title={t('workspace.designFiles')}
|
|
>
|
|
<span className="tab-icon" aria-hidden>
|
|
<Icon name="grid" size={13} />
|
|
</span>
|
|
<span className="ws-tab-label">{t('workspace.designFiles')}</span>
|
|
</button>
|
|
{tabNames.map((name) => {
|
|
const sketchEntry = sketches[name];
|
|
const dirtyMark =
|
|
sketchEntry && (sketchEntry.dirty || !sketchEntry.persisted) ? ' •' : '';
|
|
const isPending = sketchEntry && !sketchEntry.persisted;
|
|
const onDisk = files.find((f) => f.name === name);
|
|
const kind = onDisk?.kind ?? (isSketchName(name) ? 'sketch' : 'text');
|
|
return (
|
|
<Tab
|
|
key={name}
|
|
label={`${name}${dirtyMark}`}
|
|
active={activeTab === name}
|
|
onActivate={() =>
|
|
isPending ? activatePending(name) : setPersistedActive(name)
|
|
}
|
|
onClose={() => closeTab(name)}
|
|
kind={kind}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="ws-body">
|
|
{activeTab === DESIGN_FILES_TAB ? (
|
|
<DesignFilesPanel
|
|
projectId={projectId}
|
|
files={files}
|
|
onRefreshFiles={onRefreshFiles}
|
|
onOpenFile={openFile}
|
|
onDeleteFile={(name) => void handleDelete(name)}
|
|
onUpload={() => fileInputRef.current?.click()}
|
|
onPaste={() => setShowPasteDialog(true)}
|
|
onNewSketch={startNewSketch}
|
|
/>
|
|
) : isActiveSketch && activeSketch && activeFile ? (
|
|
activeSketch.loaded ? (
|
|
<SketchEditor
|
|
fileName={activeFile.name}
|
|
items={activeSketch.items}
|
|
onItemsChange={(items) => setSketchItems(activeFile.name, items)}
|
|
onSave={() => saveSketch(activeFile.name)}
|
|
saving={activeSketch.saving}
|
|
dirty={activeSketch.dirty || !activeSketch.persisted}
|
|
onCancel={() => closeTab(activeFile.name)}
|
|
/>
|
|
) : (
|
|
<div className="viewer-empty">{t('workspace.loadingSketch')}</div>
|
|
)
|
|
) : activeFile ? (
|
|
<FileViewer
|
|
projectId={projectId}
|
|
file={activeFile}
|
|
isDeck={isDeck}
|
|
onExportAsPptx={onExportAsPptx}
|
|
streaming={streaming}
|
|
/>
|
|
) : (
|
|
<div className="viewer-empty">
|
|
{t('workspace.openFromDesignFiles')}{' '}
|
|
<a
|
|
className="link"
|
|
href="#"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setActiveTab(DESIGN_FILES_TAB);
|
|
}}
|
|
>
|
|
{t('workspace.designFilesLink')}
|
|
</a>
|
|
.
|
|
</div>
|
|
)}
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
style={{ display: 'none' }}
|
|
onChange={handleFilePicked}
|
|
/>
|
|
{showPasteDialog ? (
|
|
<PasteTextDialog
|
|
onClose={() => setShowPasteDialog(false)}
|
|
onSave={async (name, content) => {
|
|
setShowPasteDialog(false);
|
|
const file = await writeProjectTextFile(projectId, name, content);
|
|
if (file) {
|
|
await onRefreshFiles();
|
|
openFile(file.name);
|
|
}
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Tab({
|
|
label,
|
|
active,
|
|
onActivate,
|
|
onClose,
|
|
closable = true,
|
|
kind,
|
|
}: {
|
|
label: string;
|
|
active: boolean;
|
|
onActivate: () => void;
|
|
onClose?: () => void;
|
|
closable?: boolean;
|
|
kind?: 'html' | 'image' | 'sketch' | 'text' | 'code' | 'binary';
|
|
}) {
|
|
const t = useT();
|
|
const iconName = kindIconName(kind);
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={`ws-tab ${active ? 'active' : ''}`}
|
|
onClick={onActivate}
|
|
role="tab"
|
|
aria-selected={active}
|
|
>
|
|
{iconName ? (
|
|
<span className="tab-icon" aria-hidden>
|
|
<Icon name={iconName} size={13} />
|
|
</span>
|
|
) : null}
|
|
<span className="ws-tab-label">{label}</span>
|
|
{closable && onClose ? (
|
|
<button
|
|
type="button"
|
|
className="ws-tab-close"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}}
|
|
title={t('workspace.closeTab')}
|
|
>
|
|
<Icon name="close" size={11} />
|
|
</button>
|
|
) : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function kindIconName(
|
|
kind?: string,
|
|
):
|
|
| 'file-code'
|
|
| 'image'
|
|
| 'pencil'
|
|
| 'file'
|
|
| null {
|
|
if (kind === 'html') return 'file-code';
|
|
if (kind === 'image') return 'image';
|
|
if (kind === 'sketch') return 'pencil';
|
|
if (kind === 'code') return 'file-code';
|
|
if (kind === 'text') return 'file';
|
|
return 'file';
|
|
}
|
|
|
|
function isSketchName(name: string): boolean {
|
|
return name.endsWith('.sketch.json');
|
|
}
|
|
|
|
function parseSketchDocument(text: string | null): SketchItem[] {
|
|
if (!text) return [];
|
|
try {
|
|
const parsed = JSON.parse(text) as SketchDocument | { items?: SketchItem[] };
|
|
return Array.isArray(parsed.items) ? parsed.items : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|