Changeset 0.37.17 (#229)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -205,14 +205,35 @@ export function ProjectDetail({ project, onBack, onUpdate }) {
|
||||
catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── File state ──
|
||||
const [expandedDirs, setExpandedDirs] = useState({});
|
||||
const [uploading, setUploading] = useState('');
|
||||
|
||||
// ── File upload ──
|
||||
const ARCHIVE_EXTS = ['.zip', '.tar.gz', '.tgz'];
|
||||
|
||||
async function uploadFiles(files) {
|
||||
const total = files.length;
|
||||
let done = 0;
|
||||
setUploading(`Uploading 0 of ${total}\u2026`);
|
||||
for (const file of files) {
|
||||
try {
|
||||
await sw.api.projects.uploadFile(project.id, file);
|
||||
sw.toast(`Uploaded ${file.name}`, 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
const isArchive = ARCHIVE_EXTS.some(e => file.name.toLowerCase().endsWith(e));
|
||||
if (isArchive && await sw.confirm(`Extract "${file.name}" contents into the project?`)) {
|
||||
const r = await sw.api.projects.uploadArchive(project.id, file);
|
||||
sw.toast(`Extracted ${r?.files_extracted || 0} files`, 'success');
|
||||
} else {
|
||||
await sw.api.projects.uploadFile(project.id, file);
|
||||
sw.toast(`Uploaded ${file.name}`, 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e.status === 413 ? 'Storage quota exceeded' : e.message;
|
||||
sw.toast(`${file.name}: ${msg}`, 'error');
|
||||
}
|
||||
done++;
|
||||
setUploading(`Uploading ${done} of ${total}\u2026`);
|
||||
}
|
||||
setUploading('');
|
||||
loadAssoc();
|
||||
}
|
||||
|
||||
@@ -230,6 +251,127 @@ export function ProjectDetail({ project, onBack, onUpdate }) {
|
||||
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
// ── File actions ──
|
||||
async function downloadFile(path) {
|
||||
try {
|
||||
const token = sw.auth?.token?.();
|
||||
const base = window.__BASE__ || '';
|
||||
const url = `${base}/api/v1/projects/${project.id}/files/download?path=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
if (!res.ok) throw new Error('Download failed');
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = path.split('/').pop() || 'download';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteFile(path) {
|
||||
const name = path.split('/').pop() || path;
|
||||
if (!await sw.confirm(`Delete "${name}"?`, true)) return;
|
||||
try {
|
||||
await sw.api.projects.deleteFile(project.id, path);
|
||||
sw.toast('Deleted', 'success');
|
||||
loadAssoc();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
const name = await sw.prompt('Folder name:');
|
||||
if (!name) return;
|
||||
try {
|
||||
await sw.api.projects.mkdir(project.id, name);
|
||||
sw.toast(`Created "${name}"`, 'success');
|
||||
loadAssoc();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function downloadAll() {
|
||||
try {
|
||||
const token = sw.auth?.token?.();
|
||||
const base = window.__BASE__ || '';
|
||||
const url = `${base}/api/v1/projects/${project.id}/archive/download?format=zip`;
|
||||
const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
if (!res.ok) throw new Error('Download failed');
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${project.name || 'project'}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── File tree builder ──
|
||||
function buildTree(files) {
|
||||
if (!files || !files.length) return [];
|
||||
// Sort: directories first, then alpha
|
||||
const sorted = [...files].sort((a, b) => {
|
||||
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
|
||||
return (a.path || '').localeCompare(b.path || '');
|
||||
});
|
||||
// Group into top-level entries (split by first path segment)
|
||||
const root = [];
|
||||
const dirMap = {};
|
||||
for (const f of sorted) {
|
||||
const parts = (f.path || f.filename || '').split('/').filter(Boolean);
|
||||
if (parts.length <= 1) {
|
||||
root.push({ ...f, name: parts[0] || f.filename, children: f.is_directory ? [] : null, depth: 0 });
|
||||
if (f.is_directory) dirMap[f.path] = root[root.length - 1];
|
||||
} else {
|
||||
// Find parent dir in root
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
const parent = dirMap[parentPath];
|
||||
const entry = { ...f, name: parts[parts.length - 1], children: f.is_directory ? [] : null, depth: parts.length - 1 };
|
||||
if (parent) {
|
||||
parent.children.push(entry);
|
||||
} else {
|
||||
root.push(entry);
|
||||
}
|
||||
if (f.is_directory) dirMap[f.path] = entry;
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function fileIcon(f) {
|
||||
if (f.is_directory) return '\uD83D\uDCC1';
|
||||
const ct = f.content_type || '';
|
||||
if (ct.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
|
||||
if (ct === 'application/pdf') return '\uD83D\uDCC4';
|
||||
if (ct.startsWith('text/')) return '\uD83D\uDCC3';
|
||||
return '\uD83D\uDCCE';
|
||||
}
|
||||
|
||||
function renderFileTree(entries) {
|
||||
if (!entries || !entries.length) return null;
|
||||
return entries.map(f => {
|
||||
const isDir = f.is_directory;
|
||||
const expanded = expandedDirs[f.path];
|
||||
const indent = (f.depth || 0) * 16;
|
||||
return html`<div key=${f.path || f.name}>
|
||||
<div class="sw-projects__file-row" style=${'padding-left:' + (8 + indent) + 'px'}>
|
||||
${isDir && html`<span class=${'sw-projects__file-chevron' + (expanded ? ' sw-projects__file-chevron--open' : '')}
|
||||
onClick=${() => setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] }))}>\u25B6</span>`}
|
||||
<span class="sw-projects__file-icon">${fileIcon(f)}</span>
|
||||
<span class="sw-projects__file-name"
|
||||
onClick=${isDir
|
||||
? () => setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] }))
|
||||
: () => downloadFile(f.path)}
|
||||
title=${isDir ? 'Toggle folder' : 'Click to download'}>
|
||||
${f.name}
|
||||
</span>
|
||||
${!isDir && html`<span class="sw-projects__file-meta">${formatBytes(f.size_bytes)}</span>`}
|
||||
${isDir && f.children && html`<span class="sw-projects__file-meta">${f.children.length} items</span>`}
|
||||
<button class="sw-projects__file-delete" onClick=${() => deleteFile(f.path)} title="Delete">\u00D7</button>
|
||||
</div>
|
||||
${isDir && expanded && f.children && renderFileTree(f.children)}
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── New conversation ──
|
||||
async function newConversation() {
|
||||
try {
|
||||
@@ -356,19 +498,28 @@ export function ProjectDetail({ project, onBack, onUpdate }) {
|
||||
${renderPicker('notes', 'No notes available.')}
|
||||
`, () => openPicker('notes'))}
|
||||
|
||||
${renderSection('files', 'Files', assoc.files.length, html`
|
||||
${assoc.files.map(f => html`
|
||||
<div key=${f.id} class="sw-projects__section-item">
|
||||
<span class="sw-projects__section-item-name">${f.filename || f.name}</span>
|
||||
<span class="sw-projects__section-item-meta">${formatBytes(f.size_bytes || f.size)}</span>
|
||||
</div>
|
||||
`)}
|
||||
${renderSection('files', 'Files', assoc.files.filter(f => !f.is_directory).length, html`
|
||||
<div class="sw-projects__file-browser">
|
||||
${assoc.files.length > 0 && html`
|
||||
<div class="sw-projects__file-actions-bar">
|
||||
<button class="sw-projects__file-action-btn" onClick=${createFolder}
|
||||
title="New folder">\uD83D\uDCC1 New folder</button>
|
||||
<button class="sw-projects__file-action-btn" onClick=${downloadAll}
|
||||
title="Download all as ZIP">\u2B07 Download all</button>
|
||||
</div>
|
||||
`}
|
||||
${renderFileTree(buildTree(assoc.files))}
|
||||
</div>
|
||||
<div class=${'sw-projects__dropzone' + (dragActive ? ' sw-projects__dropzone--active' : '')}
|
||||
onClick=${handleUploadClick}
|
||||
onDragOver=${e => { e.preventDefault(); setDragActive(true); }}
|
||||
onDragLeave=${() => setDragActive(false)}
|
||||
onDrop=${handleDrop}>
|
||||
${dragActive ? 'Drop files here' : 'Add PDFs, documents, or other text to reference in this project.'}
|
||||
${uploading ? uploading
|
||||
: dragActive ? 'Drop files here'
|
||||
: assoc.files.length === 0
|
||||
? 'Add PDFs, documents, or other text to reference in this project.'
|
||||
: 'Drop files or click to upload more'}
|
||||
</div>
|
||||
`, handleUploadClick)}
|
||||
</div>`;
|
||||
|
||||
Reference in New Issue
Block a user