Changeset 0.37.17 (#229)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-24 16:50:00 +00:00
committed by xcaliber
parent e0687d2ea6
commit 96a4f16bc5
27 changed files with 4107 additions and 107 deletions

View File

@@ -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>`;