Changeset 0.37.17 (#229)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -647,6 +647,128 @@
|
||||
border-color: var(--text, #e8e8ed);
|
||||
}
|
||||
|
||||
/* ── File Browser (v0.37.17) ──────────────── */
|
||||
|
||||
.sw-projects__file-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sw-projects__file-actions-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px 0 8px;
|
||||
border-bottom: 1px solid var(--border, #2e2e35);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-projects__file-action-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
color: var(--text-3, #999);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.sw-projects__file-action-btn:hover {
|
||||
color: var(--text-1, #fff);
|
||||
border-color: var(--text-3, #999);
|
||||
}
|
||||
|
||||
.sw-projects__file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.sw-projects__file-row:hover {
|
||||
background: var(--bg-3, rgba(255,255,255,0.04));
|
||||
}
|
||||
|
||||
.sw-projects__file-chevron {
|
||||
font-size: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-3, #999);
|
||||
transition: transform 0.15s;
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sw-projects__file-chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sw-projects__file-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sw-projects__file-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #ccc);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sw-projects__file-name:hover {
|
||||
color: var(--text-1, #fff);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sw-projects__file-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #999);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-projects__file-delete {
|
||||
opacity: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger, #ef4444);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0 2px;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.sw-projects__file-row:hover .sw-projects__file-delete {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.sw-projects__file-delete:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.sw-projects__dropzone {
|
||||
margin-top: 8px;
|
||||
padding: 16px;
|
||||
border: 2px dashed var(--border, #2e2e35);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #999);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.sw-projects__dropzone:hover {
|
||||
border-color: var(--text-3, #999);
|
||||
color: var(--text-2, #ccc);
|
||||
}
|
||||
.sw-projects__dropzone--active {
|
||||
border-color: var(--accent, #4f8cff);
|
||||
color: var(--accent, #4f8cff);
|
||||
background: rgba(79, 140, 255, 0.05);
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -140,13 +140,17 @@ export function createDomains(restClient) {
|
||||
notes: (id) => rc.get(`/api/v1/projects/${id}/notes`),
|
||||
addNote: (id, noteId) => rc.post(`/api/v1/projects/${id}/notes`, { note_id: noteId }),
|
||||
removeNote: (id, noteId) => rc.del(`/api/v1/projects/${id}/notes/${noteId}`),
|
||||
files: (id) => rc.get(`/api/v1/projects/${id}/files`),
|
||||
uploadFile: (id, file) => rc.upload(`/api/v1/projects/${id}/files`, file),
|
||||
files: (id, opts) => rc.get(`/api/v1/projects/${id}/files` + _qs(opts)),
|
||||
uploadFile: (id, file, path) => rc.upload(`/api/v1/projects/${id}/files` + (path ? _qs({ path }) : ''), file),
|
||||
deleteFile: (id, path) => rc.del(`/api/v1/projects/${id}/files` + _qs({ path })),
|
||||
mkdir: (id, path) => rc.post(`/api/v1/projects/${id}/files/mkdir`, { path }),
|
||||
uploadArchive: (id, file) => rc.upload(`/api/v1/projects/${id}/archive/upload`, file),
|
||||
},
|
||||
|
||||
// ── 7. Workspaces ──────────────────────
|
||||
workspaces: {
|
||||
...crud(rc, '/api/v1/workspaces'),
|
||||
update: (id, data) => rc.patch(`/api/v1/workspaces/${id}`, data),
|
||||
files: (id, opts) => rc.get(`/api/v1/workspaces/${id}/files` + _qs(opts)),
|
||||
readFile: (id, path) => rc.get(`/api/v1/workspaces/${id}/files/read` + _qs({ path })),
|
||||
writeFile: (id, path, content) => rc.put(`/api/v1/workspaces/${id}/files/write` + _qs({ path }), content),
|
||||
@@ -187,11 +191,12 @@ export function createDomains(restClient) {
|
||||
notifications: {
|
||||
list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)),
|
||||
unreadCount: () => rc.get('/api/v1/notifications/unread-count'),
|
||||
markRead: (id) => rc.post(`/api/v1/notifications/${id}/read`, {}),
|
||||
markRead: (id) => rc.patch(`/api/v1/notifications/${id}/read`, {}),
|
||||
markAllRead: () => rc.post('/api/v1/notifications/mark-all-read', {}),
|
||||
prefs: () => rc.get('/api/v1/notifications/preferences'),
|
||||
setPref: (type, data) => rc.put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data),
|
||||
delPref: (type) => rc.del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`),
|
||||
del: (id) => rc.del(`/api/v1/notifications/${id}`),
|
||||
},
|
||||
|
||||
// ── 12. Extensions ─────────────────────
|
||||
@@ -272,6 +277,8 @@ export function createDomains(restClient) {
|
||||
// ── 15. Workflows ──────────────────────
|
||||
workflows: {
|
||||
...crud(rc, '/api/v1/workflows'),
|
||||
update: (id, data) => rc.patch(`/api/v1/workflows/${id}`, data),
|
||||
stages: (id) => rc.get(`/api/v1/workflows/${id}/stages`),
|
||||
instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)),
|
||||
advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data),
|
||||
reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data),
|
||||
@@ -294,8 +301,8 @@ export function createDomains(restClient) {
|
||||
tasks: {
|
||||
...crud(rc, '/api/v1/tasks'),
|
||||
runs: (id, opts) => rc.get(`/api/v1/tasks/${id}/runs` + _qs(opts)),
|
||||
start: (id, data) => rc.post(`/api/v1/tasks/${id}/start`, data || {}),
|
||||
stop: (id) => rc.post(`/api/v1/tasks/${id}/stop`, {}),
|
||||
start: (id, data) => rc.post(`/api/v1/tasks/${id}/run`, data || {}),
|
||||
stop: (id) => rc.post(`/api/v1/tasks/${id}/kill`, {}),
|
||||
},
|
||||
|
||||
// ── 17. Surfaces ───────────────────────
|
||||
@@ -522,13 +529,9 @@ export function createDomains(restClient) {
|
||||
del: (id) => rc.del(`/api/v1/folders/${id}`),
|
||||
},
|
||||
|
||||
notifications: {
|
||||
list: (params) => rc.get('/api/v1/notifications', params),
|
||||
unreadCount: () => rc.get('/api/v1/notifications/unread-count'),
|
||||
markRead: (id) => rc.patch(`/api/v1/notifications/${id}/read`),
|
||||
markAllRead: () => rc.post('/api/v1/notifications/mark-all-read'),
|
||||
del: (id) => rc.del(`/api/v1/notifications/${id}`),
|
||||
},
|
||||
// NOTE: notifications is defined in the main domain block above (§11).
|
||||
// The duplicate here was overwriting prefs/setPref/delPref methods.
|
||||
// Removed — see line ~190 for the canonical definition.
|
||||
|
||||
files: {
|
||||
get: (id) => rc.get(`/api/v1/files/${id}`),
|
||||
|
||||
@@ -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