Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
596 lines
34 KiB
JavaScript
596 lines
34 KiB
JavaScript
window.__ModuleLoader__.load({
|
|
id: "dsh-project-tree",
|
|
factory: (require) => {
|
|
var module = { exports: {} };
|
|
var exports = module.exports;
|
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
const react = require("react");
|
|
const runtimeClient = require("@deepseek-ai/dsh-client-runtime/client");
|
|
const e = react.createElement;
|
|
|
|
const name = "dsh-project-tree";
|
|
const inject = ["slots", "sessions", "workspaces"];
|
|
|
|
const ROUTE_PREFIX = "/dsh-project-tree";
|
|
const VIEW_PERSIST_KEY = "dsh-project-tree.view";
|
|
const STYLE_ID = "dsh-project-tree-style";
|
|
const RELOAD_MS = 4000;
|
|
const INDENT = 10;
|
|
|
|
const STYLE_TEXT = [
|
|
".dpt{display:flex;flex-direction:column;height:100%;overflow:auto;font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary);font-size:13px;padding:6px 12px 24px 0}",
|
|
".dpt__hint{padding:8px 10px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
|
|
".dpt__notice{margin:4px 8px;padding:6px 8px;border-radius:6px;background:var(--dsw-alias-status-error,#b34);color:#fff;font-size:12px}",
|
|
".dpt__bar{display:flex;align-items:center;justify-content:space-between;padding:2px 8px 6px;color:var(--dsw-alias-label-tertiary)}",
|
|
".dpt__bar b{font-size:11px;text-transform:uppercase;letter-spacing:.05em;font-weight:600}",
|
|
".dpt__iconbtn{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:5px;cursor:pointer;color:var(--dsw-alias-label-tertiary);background:transparent;border:none}",
|
|
".dpt__iconbtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
|
|
".dpt__row{position:relative;display:flex;align-items:center;height:28px;padding-left:4px;cursor:pointer;white-space:nowrap;user-select:none}",
|
|
".dpt__body{position:relative;flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:5px;height:24px;padding:0 6px;border-radius:6px;box-sizing:border-box}",
|
|
".dpt__row:hover .dpt__body{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
".dpt__row[data-active=true] .dpt__body{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--dsw-alias-label-primary,#dddddd) 45%, transparent)}",
|
|
".dpt__row[data-drop=into] .dpt__body{background:var(--dsw-alias-interactive-bg-active,rgba(80,140,255,.18));box-shadow:inset 0 0 0 1px var(--dsw-alias-accent,#58f)}",
|
|
".dpt__row[data-drop=before] .dpt__body::before{content:'';position:absolute;left:0;right:0;top:-2px;height:2px;background:var(--dsw-alias-accent,#58f);border-radius:2px}",
|
|
".dpt__row[data-dragging=true]{opacity:.4}",
|
|
".dpt__twist{display:inline-flex;justify-content:center;width:14px;color:var(--dsw-alias-label-tertiary);flex:0 0 auto;transition:transform .1s}",
|
|
".dpt__ico{display:inline-flex;flex:0 0 auto;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}",
|
|
".dpt__ico[data-kind=session]{color:var(--dsw-alias-label-tertiary)}",
|
|
".dpt__label{overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;min-width:0}",
|
|
".dpt__sub{display:none;color:var(--dsw-alias-label-tertiary);font-size:11px;flex:0 0 auto;max-width:110px;overflow:hidden;text-overflow:ellipsis}",
|
|
".dpt__row:hover .dpt__sub{display:inline-block}",
|
|
".dpt__actions{display:none;align-items:center;gap:2px;flex:0 0 auto}",
|
|
".dpt__row:hover .dpt__actions{display:inline-flex}",
|
|
".dpt__work{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:4px;flex:0 0 auto;color:var(--dsw-alias-label-tertiary);cursor:pointer;opacity:.55}",
|
|
".dpt__work:hover{background:var(--dsw-alias-interactive-bg-active,rgba(255,255,255,.08));color:var(--dsw-alias-label-primary);opacity:1}",
|
|
".dpt__work[data-on=true]{color:var(--dsw-alias-status-success,#3b9);opacity:1}",
|
|
".dpt__act{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:4px;color:var(--dsw-alias-label-tertiary)}",
|
|
".dpt__act:hover{background:var(--dsw-alias-interactive-bg-active,rgba(255,255,255,.08));color:var(--dsw-alias-label-primary)}",
|
|
".dpt__children{position:relative;margin-left:" + (INDENT + 6) + "px;border-left:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.12))}",
|
|
".dpt__dot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-label-tertiary)}",
|
|
".dpt__dot[data-state=running]{background:var(--dsw-alias-status-success,#3b9)}",
|
|
".dpt__dot[data-state=waiting]{background:var(--dsw-alias-status-warning,#e94)}",
|
|
".dpt__dot[data-state=idle]{background:transparent;box-shadow:inset 0 0 0 1px var(--dsw-alias-label-tertiary)}",
|
|
".dpt__menu{position:fixed;z-index:9999;min-width:190px;max-height:70vh;overflow:auto;background:var(--dsw-alias-surface-overlay,#232323);border:1px solid var(--dsw-alias-border-l2,#444);border-radius:8px;padding:4px;box-shadow:0 8px 30px rgba(0,0,0,.45);font-size:13px}",
|
|
".dpt__mi{display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:5px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
|
|
".dpt__mi:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
".dpt__mi small{color:var(--dsw-alias-label-tertiary);margin-left:auto;padding-left:12px}",
|
|
".dpt__sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l2,#444)}"
|
|
].join("");
|
|
|
|
function injectStyle() {
|
|
if (document.getElementById(STYLE_ID) !== null) return;
|
|
const style = document.createElement("style");
|
|
style.id = STYLE_ID;
|
|
style.dataset.plugin = name;
|
|
style.textContent = STYLE_TEXT;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function svg(paths, size) {
|
|
return e("svg", { width: size || 15, height: size || 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round", strokeLinejoin: "round" }, paths);
|
|
}
|
|
const ICON = {
|
|
chevron: (open) => e("span", { className: "dpt__twist", style: { transform: open ? "rotate(90deg)" : "none" } }, svg([e("path", { key: "p", d: "M6 4l4 4-4 4" })], 12)),
|
|
project: () => svg([e("path", { key: "a", d: "M2 4.2l6-2.2 6 2.2v7.6l-6 2.2-6-2.2z" }), e("path", { key: "b", d: "M2 4.2l6 2.3 6-2.3M8 6.5v7.5" })]),
|
|
group: () => svg([e("path", { key: "a", d: "M2 5.2a1 1 0 011-1h3l1.4 1.4H13a1 1 0 011 1v5.2a1 1 0 01-1 1H3a1 1 0 01-1-1z" })]),
|
|
task: () => svg([e("path", { key: "a", d: "M3 4h10M3 8h10M3 12h7" })]),
|
|
plus: () => svg([e("path", { key: "a", d: "M8 3.5v9M3.5 8h9" }), ], 13),
|
|
edit: () => svg([e("path", { key: "a", d: "M9.5 3.5l3 3L6 13l-3.2.7L3.5 10z" })], 13),
|
|
trash: () => svg([e("path", { key: "a", d: "M3 4.5h10M6 4.5V3h4v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" })], 13),
|
|
newchat: () => svg([e("path", { key: "a", d: "M13.5 8.5v3a1.5 1.5 0 01-1.5 1.5H5l-2.5 2V5A1.5 1.5 0 014 3.5h4" }), e("path", { key: "b", d: "M11 2.5v4M9 4.5h4" })], 14),
|
|
dir: () => svg([e("path", { key: "a", d: "M2 4.2a1 1 0 011-1h3l1.4 1.4H14v7a1 1 0 01-1 1H3a1 1 0 01-1-1z" })], 13),
|
|
work: (on) => svg([
|
|
e("circle", { key: "c", cx: 8, cy: 8, r: 5.2, fill: on ? "currentColor" : "none" }),
|
|
on ? e("path", { key: "k", d: "M5.7 8.1l1.6 1.6 3-3.4", stroke: "var(--dsw-alias-surface-overlay,#232323)" }) : null
|
|
], 14)
|
|
};
|
|
|
|
const TRANSPORT_ERROR = { code: "route-unavailable", message: "project-tree route unavailable" };
|
|
async function api(path, body) {
|
|
let response;
|
|
try {
|
|
response = await fetch(ROUTE_PREFIX + "/" + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body ?? {}) });
|
|
} catch { return { ok: false, error: TRANSPORT_ERROR }; }
|
|
let payload;
|
|
try { payload = await response.json(); } catch { return { ok: false, error: TRANSPORT_ERROR }; }
|
|
if (response.ok) return { ok: true, value: payload };
|
|
return { ok: false, error: payload?.error ?? TRANSPORT_ERROR };
|
|
}
|
|
|
|
function createViewStore() {
|
|
return runtimeClient.defineStore({
|
|
init: () => ({ collapsed: {} }),
|
|
persist: VIEW_PERSIST_KEY,
|
|
actions: { setCollapsed: (draft, key, collapsed) => { draft.collapsed[key] = collapsed; } }
|
|
});
|
|
}
|
|
|
|
function sessionVisible(summary, archived) {
|
|
return summary !== undefined && summary.origin !== "subagent" && !archived.has(summary.id) && !summary.blank;
|
|
}
|
|
function sessionState(summary) {
|
|
if (summary.running) return "running";
|
|
if (summary.pendingInteraction) return "waiting";
|
|
if (summary.completed) return "completed";
|
|
return "idle";
|
|
}
|
|
function byOrder(a, b) {
|
|
const ai = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
|
|
const bi = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
|
|
if (ai !== bi) return ai - bi;
|
|
return String(a.name).localeCompare(String(b.name));
|
|
}
|
|
function baseName(path) {
|
|
if (typeof path !== "string" || path.length === 0) return "";
|
|
const parts = path.replace(/[/\\]+$/, "").split(/[/\\]/);
|
|
return parts[parts.length - 1] || path;
|
|
}
|
|
function insertBefore(ids, movedId, beforeId) {
|
|
const without = ids.filter((id) => id !== movedId);
|
|
if (beforeId === null || beforeId === undefined) return [...without, movedId];
|
|
const at = without.indexOf(beforeId);
|
|
if (at < 0) return [...without, movedId];
|
|
return [...without.slice(0, at), movedId, ...without.slice(at)];
|
|
}
|
|
function endIndex(items) { return items.reduce((m, x) => Math.max(m, x.sortIndex ?? 0), 0) + 1; }
|
|
function applyOrder(items, orderedIds) { const idx = new Map(orderedIds.map((id, i) => [id, i])); for (const it of items) if (idx.has(it.id)) it.sortIndex = idx.get(it.id); }
|
|
|
|
const mut = {
|
|
assign: (sessionId, taskId) => (t) => {
|
|
for (const task of t.tasks) task.sessionIds = task.sessionIds.filter((s) => s !== sessionId);
|
|
if (taskId) { const task = t.tasks.find((x) => x.id === taskId); if (task) task.sessionIds = [sessionId, ...task.sessionIds]; }
|
|
return t;
|
|
},
|
|
taskMove: (taskId, groupId) => (t) => { const task = t.tasks.find((x) => x.id === taskId); if (task) { task.groupId = groupId; task.sortIndex = endIndex(t.tasks.filter((x) => x.groupId === groupId && x.id !== taskId)); } return t; },
|
|
tasksReorder: (groupId, orderedIds) => (t) => { applyOrder(t.tasks.filter((x) => x.groupId === groupId), orderedIds); return t; },
|
|
groupMove: (groupId, projectId) => (t) => { const group = t.groups.find((x) => x.id === groupId); if (group) { group.projectId = projectId; group.sortIndex = endIndex(t.groups.filter((x) => x.projectId === projectId && x.id !== groupId)); } return t; },
|
|
groupsReorder: (projectId, orderedIds) => (t) => { applyOrder(t.groups.filter((x) => x.projectId === projectId), orderedIds); return t; },
|
|
projectsReorder: (orderedIds) => (t) => { applyOrder(t.projects, orderedIds); return t; },
|
|
renameProject: (id, name) => (t) => { const p = t.projects.find((x) => x.id === id); if (p) p.name = name; return t; },
|
|
renameGroup: (id, name) => (t) => { const g = t.groups.find((x) => x.id === id); if (g) g.name = name; return t; },
|
|
renameTask: (id, name) => (t) => { const tk = t.tasks.find((x) => x.id === id); if (tk) tk.name = name; return t; },
|
|
deleteProject: (id) => (t) => { const groups = new Set(t.groups.filter((g) => g.projectId === id).map((g) => g.id)); t.projects = t.projects.filter((p) => p.id !== id); t.groups = t.groups.filter((g) => g.projectId !== id); t.tasks = t.tasks.filter((tk) => !groups.has(tk.groupId)); return t; },
|
|
deleteGroup: (id) => (t) => { t.groups = t.groups.filter((g) => g.id !== id); t.tasks = t.tasks.filter((tk) => tk.groupId !== id); return t; },
|
|
deleteTask: (id) => (t) => { t.tasks = t.tasks.filter((tk) => tk.id !== id); return t; },
|
|
inWork: (id, value) => (t) => { const has = (t.inWork || []).includes(id); t.inWork = value ? (has ? t.inWork : [...(t.inWork || []), id]) : (t.inWork || []).filter((x) => x !== id); return t; }
|
|
};
|
|
|
|
let DRAG = null;
|
|
|
|
function ProjectTreeBrowser(props) {
|
|
injectStyle();
|
|
const { useSessions, useWorkspaces, useStore, actions } = props;
|
|
const A = props;
|
|
|
|
const list = useSessions((state) => state);
|
|
const wsItems = useWorkspaces((state) => state.items);
|
|
const archivedSessionIds = useWorkspaces((state) => state.archivedSessionIds);
|
|
const collapsed = useStore((state) => state.collapsed);
|
|
|
|
const [tree, setTree] = react.useState(null);
|
|
const [menu, setMenu] = react.useState(null);
|
|
const [notice, setNotice] = react.useState(null);
|
|
const pendingAssign = react.useRef(null);
|
|
const opSeq = react.useRef(0);
|
|
|
|
const archived = react.useMemo(() => new Set(archivedSessionIds ?? []), [archivedSessionIds]);
|
|
|
|
const flash = react.useCallback((error) => {
|
|
const code = typeof error === "string" ? error : error?.code ?? error?.message ?? "error";
|
|
setNotice(String(code));
|
|
window.setTimeout(() => setNotice(null), 3500);
|
|
}, []);
|
|
|
|
// Guarded reload: drop the result if a newer mutation started while we
|
|
// were fetching, so a background poll never clobbers an optimistic edit.
|
|
const reload = react.useCallback(async () => {
|
|
const seq = opSeq.current;
|
|
const result = await A.listTree();
|
|
if (result.ok && opSeq.current === seq) setTree(result.value);
|
|
}, []);
|
|
|
|
react.useEffect(() => {
|
|
let active = true;
|
|
const tick = () => { if (active && document.visibilityState !== "hidden") void reload(); };
|
|
tick();
|
|
const timer = window.setInterval(tick, RELOAD_MS);
|
|
return () => { active = false; window.clearInterval(timer); };
|
|
}, [reload]);
|
|
|
|
// Optimistic mutation: patch local tree immediately, fire the request(s),
|
|
// then reconcile from the server (only if no newer op superseded us).
|
|
const commit = react.useCallback((mutator, apiCall) => {
|
|
const mySeq = ++opSeq.current;
|
|
if (mutator) setTree((prev) => prev ? mutator(JSON.parse(JSON.stringify(prev))) : prev);
|
|
Promise.resolve(apiCall()).then(async (res) => {
|
|
if (res && res.ok === false) flash(res.error);
|
|
const result = await A.listTree();
|
|
if (result.ok && opSeq.current === mySeq) setTree(result.value);
|
|
});
|
|
}, [flash]);
|
|
|
|
// Awaiting variant for flows that need the server result (create → id).
|
|
const run = react.useCallback(async (result) => {
|
|
opSeq.current++;
|
|
if (result && result.ok === false) { flash(result.error); return null; }
|
|
await reload();
|
|
return result ? result.value : true;
|
|
}, [reload, flash]);
|
|
|
|
const isCollapsed = (key) => collapsed[key] !== false;
|
|
const toggle = (key) => actions.setCollapsed(key, !isCollapsed(key));
|
|
|
|
const closeMenu = react.useCallback(() => setMenu(null), []);
|
|
react.useEffect(() => {
|
|
if (menu === null) return undefined;
|
|
const onDown = () => closeMenu();
|
|
window.addEventListener("mousedown", onDown);
|
|
window.addEventListener("resize", onDown);
|
|
return () => { window.removeEventListener("mousedown", onDown); window.removeEventListener("resize", onDown); };
|
|
}, [menu, closeMenu]);
|
|
const openMenu = (event, items) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setMenu({ x: event.clientX, y: event.clientY, items: items.filter(Boolean) });
|
|
};
|
|
|
|
const sessionWorkspace = react.useMemo(() => {
|
|
const map = new Map();
|
|
for (const workspace of wsItems ?? []) for (const id of workspace.sessionIds) map.set(id, workspace);
|
|
return map;
|
|
}, [wsItems]);
|
|
|
|
const view = react.useMemo(() => {
|
|
const projects = (tree?.projects ?? []).slice().sort(byOrder);
|
|
const groups = tree?.groups ?? [];
|
|
const tasks = tree?.tasks ?? [];
|
|
const visible = (summary) => sessionVisible(summary, archived);
|
|
const groupsByProject = new Map();
|
|
for (const group of groups) {
|
|
const bucket = groupsByProject.get(group.projectId) ?? [];
|
|
bucket.push(group); groupsByProject.set(group.projectId, bucket);
|
|
}
|
|
const tasksByGroup = new Map();
|
|
for (const task of tasks) {
|
|
const bucket = tasksByGroup.get(task.groupId) ?? [];
|
|
bucket.push(task); tasksByGroup.set(task.groupId, bucket);
|
|
}
|
|
const claimed = new Set();
|
|
const renderedProjects = projects.map((project) => ({
|
|
id: project.id, name: project.name,
|
|
groups: (groupsByProject.get(project.id) ?? []).slice().sort(byOrder).map((group) => ({
|
|
id: group.id, name: group.name, projectId: project.id,
|
|
tasks: (tasksByGroup.get(group.id) ?? []).slice().sort(byOrder).map((task) => {
|
|
const sessions = task.sessionIds.map((id) => list.byId[id]).filter(visible);
|
|
for (const s of sessions) claimed.add(s.id);
|
|
return { id: task.id, name: task.name, groupId: group.id, sessionIds: task.sessionIds, sessions };
|
|
})
|
|
}))
|
|
}));
|
|
const unsorted = Object.values(list.byId).filter(visible).filter((s) => !claimed.has(s.id))
|
|
.sort((a, b) => String(a.displayTitle).localeCompare(String(b.displayTitle)));
|
|
return { projects: renderedProjects, unsorted, workspaces: tree?.workspaces ?? [] };
|
|
}, [tree, list, archived]);
|
|
|
|
react.useEffect(() => {
|
|
const p = pendingAssign.current;
|
|
if (p === null) return;
|
|
const now = new Set((wsItems ?? []).flatMap((w) => w.sessionIds));
|
|
const fresh = [...now].find((id) => !p.known.has(id) && (sessionWorkspace.get(id)?.workspaceId ?? sessionWorkspace.get(id)?.id) === p.workspaceId);
|
|
if (fresh !== undefined) {
|
|
pendingAssign.current = null;
|
|
void run(api("session-assign", { sessionId: fresh, taskId: p.taskId }));
|
|
}
|
|
}, [wsItems, sessionWorkspace, run]);
|
|
|
|
const groupTaskIds = (groupId) => view.projects.flatMap((p) => p.groups).filter((g) => g.id === groupId).flatMap((g) => g.tasks.map((t) => t.id));
|
|
const projectGroupIds = (projectId) => view.projects.filter((p) => p.id === projectId).flatMap((p) => p.groups.map((g) => g.id));
|
|
const projectIds = () => view.projects.map((p) => p.id);
|
|
|
|
const onDrop = (target) => {
|
|
const drag = DRAG; DRAG = null;
|
|
if (drag === null || (drag.type === target.type && drag.id === target.id)) return;
|
|
if (drag.type === "session") {
|
|
if (target.type === "task") return commit(mut.assign(drag.id, target.id), () => api("session-assign", { sessionId: drag.id, taskId: target.id }));
|
|
if (target.type === "unsorted") return commit(mut.assign(drag.id, null), () => api("session-assign", { sessionId: drag.id, taskId: null }));
|
|
return;
|
|
}
|
|
if (drag.type === "task") {
|
|
if (target.type === "group") return commit(mut.taskMove(drag.id, target.id), () => api("task-move", { taskId: drag.id, groupId: target.id }));
|
|
if (target.type === "task") {
|
|
const ordered = insertBefore(groupTaskIds(target.groupId).filter((id) => id !== drag.id), drag.id, target.id);
|
|
if (target.groupId !== drag.groupId) {
|
|
return commit((t) => mut.tasksReorder(target.groupId, ordered)(mut.taskMove(drag.id, target.groupId)(t)),
|
|
async () => { const r = await api("task-move", { taskId: drag.id, groupId: target.groupId }); return r.ok === false ? r : api("tasks-reorder", { groupId: target.groupId, orderedIds: ordered }); });
|
|
}
|
|
return commit(mut.tasksReorder(target.groupId, ordered), () => api("tasks-reorder", { groupId: target.groupId, orderedIds: ordered }));
|
|
}
|
|
return;
|
|
}
|
|
if (drag.type === "group") {
|
|
if (target.type === "project") return commit(mut.groupMove(drag.id, target.id), () => api("group-move", { groupId: drag.id, projectId: target.id }));
|
|
if (target.type === "group") {
|
|
const ordered = insertBefore(projectGroupIds(target.projectId).filter((id) => id !== drag.id), drag.id, target.id);
|
|
if (target.projectId !== drag.projectId) {
|
|
return commit((t) => mut.groupsReorder(target.projectId, ordered)(mut.groupMove(drag.id, target.projectId)(t)),
|
|
async () => { const r = await api("group-move", { groupId: drag.id, projectId: target.projectId }); return r.ok === false ? r : api("groups-reorder", { projectId: target.projectId, orderedIds: ordered }); });
|
|
}
|
|
return commit(mut.groupsReorder(target.projectId, ordered), () => api("groups-reorder", { projectId: target.projectId, orderedIds: ordered }));
|
|
}
|
|
return;
|
|
}
|
|
if (drag.type === "project" && target.type === "project") {
|
|
const ordered = insertBefore(projectIds(), drag.id, target.id);
|
|
return commit(mut.projectsReorder(ordered), () => api("projects-reorder", { orderedIds: ordered }));
|
|
}
|
|
};
|
|
|
|
const canDrop = (drag, node) =>
|
|
(drag.type === "session" && (node.type === "task" || node.type === "unsorted")) ||
|
|
(drag.type === "task" && (node.type === "group" || node.type === "task")) ||
|
|
(drag.type === "group" && (node.type === "project" || node.type === "group")) ||
|
|
(drag.type === "project" && node.type === "project");
|
|
|
|
const dropAttrs = (node) => ({
|
|
onDragOver: (ev) => {
|
|
if (DRAG === null || !canDrop(DRAG, node)) return;
|
|
if (DRAG.type === node.type && DRAG.id === node.id) return;
|
|
ev.preventDefault();
|
|
ev.dataTransfer.dropEffect = "move";
|
|
ev.currentTarget.dataset.drop = DRAG.type === node.type ? "before" : "into";
|
|
},
|
|
onDragLeave: (ev) => { delete ev.currentTarget.dataset.drop; },
|
|
onDrop: (ev) => { ev.preventDefault(); ev.stopPropagation(); delete ev.currentTarget.dataset.drop; void onDrop(node); }
|
|
});
|
|
const dragAttrs = (item) => ({
|
|
draggable: true,
|
|
onDragStart: (ev) => { DRAG = item; ev.dataTransfer.effectAllowed = "move"; try { ev.dataTransfer.setData("text/plain", item.id); } catch {} },
|
|
onDragEnd: () => { DRAG = null; }
|
|
});
|
|
|
|
const promptName = (label, current) => {
|
|
const v = window.prompt(label, current ?? "");
|
|
if (v === null) return null;
|
|
const t = v.trim();
|
|
return t.length === 0 ? null : t;
|
|
};
|
|
const newSessionInTask = (taskId) => {
|
|
const workspaces = view.workspaces ?? [];
|
|
if (workspaces.length === 0) return void flash("no-workspaces");
|
|
openMenuAt(lastMenuEvent.current, workspaces.map((w) => ({
|
|
icon: ICON.dir(), label: w.title, hint: baseName(w.path),
|
|
run: () => {
|
|
const known = new Set((wsItems ?? []).flatMap((x) => x.sessionIds));
|
|
pendingAssign.current = { taskId, workspaceId: w.id, known };
|
|
A.startSession(w.id);
|
|
flash("starting-session…");
|
|
}
|
|
})));
|
|
};
|
|
const lastMenuEvent = react.useRef(null);
|
|
const openMenuAt = (ev, items) => { if (ev) setMenu({ x: ev.clientX, y: ev.clientY, items: items.filter(Boolean) }); };
|
|
|
|
const actBtn = (icon, title, onClick) => e("span", { className: "dpt__act", title, onClick: (ev) => { ev.stopPropagation(); onClick(ev); } }, icon);
|
|
|
|
const inWorkSet = new Set(tree?.inWork ?? []);
|
|
const workToggle = (id) => e("span", {
|
|
key: "work",
|
|
className: "dpt__work",
|
|
"data-on": inWorkSet.has(id),
|
|
title: inWorkSet.has(id) ? "In work — click to stop" : "Mark as in work",
|
|
onClick: (ev) => { ev.stopPropagation(); const value = !inWorkSet.has(id); commit(mut.inWork(id, value), () => api("in-work", { id, value })); }
|
|
}, ICON.work(inWorkSet.has(id)));
|
|
|
|
const row = (opts) => {
|
|
const [twist, ...body] = opts.children;
|
|
return e("div", Object.assign({
|
|
className: "dpt__row",
|
|
"data-type": opts.dragType,
|
|
"data-id": opts.id,
|
|
"data-inwork": opts.inWork === true,
|
|
"data-active": opts.active === true,
|
|
"data-dragging": DRAG !== null && DRAG.type === opts.dragType && DRAG.id === opts.id,
|
|
onClick: opts.onClick,
|
|
onContextMenu: opts.onContextMenu
|
|
}, opts.drag ? dragAttrs({ type: opts.dragType, id: opts.id, groupId: opts.groupId, projectId: opts.projectId }) : {}, opts.drop || {}), [
|
|
twist,
|
|
e("div", { key: "body", className: "dpt__body" }, body)
|
|
]);
|
|
};
|
|
|
|
const sessionRow = (summary) => {
|
|
const workspace = sessionWorkspace.get(summary.id);
|
|
return row({
|
|
id: summary.id, dragType: "session", drag: true, inWork: inWorkSet.has(summary.id), active: list.current === summary.id,
|
|
onClick: () => A.openSession(summary.id),
|
|
onContextMenu: (ev) => { lastMenuEvent.current = ev; openMenu(ev, sessionMenuItems(summary.id)); },
|
|
children: [
|
|
e("span", { key: "t", className: "dpt__twist" }),
|
|
e("span", { key: "d", className: "dpt__ico", "data-kind": "session" }, e("span", { className: "dpt__dot", "data-state": sessionState(summary) })),
|
|
e("span", { key: "l", className: "dpt__label", title: summary.displayTitle }, summary.displayTitle || "Untitled"),
|
|
workspace && e("span", { key: "w", className: "dpt__sub", title: workspace.path }, baseName(workspace.path)),
|
|
workToggle(summary.id)
|
|
]
|
|
});
|
|
};
|
|
|
|
const taskNode = (task) => {
|
|
const key = "t:" + task.id;
|
|
const open = !isCollapsed(key);
|
|
return e(react.Fragment, { key: task.id }, [
|
|
row({
|
|
id: task.id, groupId: task.groupId, dragType: "task", drag: true,
|
|
drop: dropAttrs({ type: "task", id: task.id, groupId: task.groupId }),
|
|
onClick: () => toggle(key),
|
|
onContextMenu: (ev) => { lastMenuEvent.current = ev; openMenu(ev, [
|
|
{ icon: ICON.newchat(), label: "New session here…", run: () => newSessionInTask(task.id) },
|
|
{ icon: ICON.edit(), label: "Rename task", run: () => { const n = promptName("Rename task", task.name); if (n) commit(mut.renameTask(task.id, n), () => api("task-rename", { taskId: task.id, name: n })); } },
|
|
{ icon: ICON.trash(), label: "Delete task", run: () => { if (window.confirm(`Delete task "${task.name}"? Its sessions return to Unsorted.`)) commit(mut.deleteTask(task.id), () => api("task-delete", { taskId: task.id })); } }
|
|
]); },
|
|
children: [
|
|
ICON.chevron(open),
|
|
e("span", { key: "i", className: "dpt__ico" }, ICON.task()),
|
|
e("span", { key: "l", className: "dpt__label" }, task.name),
|
|
e("span", { key: "a", className: "dpt__actions" }, [
|
|
actBtn(ICON.newchat(), "New session here", () => newSessionInTask(task.id))
|
|
])
|
|
]
|
|
}),
|
|
open && e("div", { key: "c", className: "dpt__children", ...dropAttrs({ type: "task", id: task.id, groupId: task.groupId }) }, task.sessions.map((s) => sessionRow(s)))
|
|
]);
|
|
};
|
|
|
|
const groupNode = (group) => {
|
|
const key = "g:" + group.id;
|
|
const open = !isCollapsed(key);
|
|
return e(react.Fragment, { key: group.id }, [
|
|
row({
|
|
id: group.id, projectId: group.projectId, dragType: "group", drag: true,
|
|
drop: dropAttrs({ type: "group", id: group.id, projectId: group.projectId }),
|
|
onClick: () => toggle(key),
|
|
onContextMenu: (ev) => { lastMenuEvent.current = ev; openMenu(ev, [
|
|
{ icon: ICON.plus(), label: "New task…", run: async () => { const n = promptName("New task name"); if (n) await run(api("task-create", { groupId: group.id, name: n })); } },
|
|
{ icon: ICON.edit(), label: "Rename group", run: () => { const n = promptName("Rename group", group.name); if (n) commit(mut.renameGroup(group.id, n), () => api("group-rename", { groupId: group.id, name: n })); } },
|
|
{ icon: ICON.trash(), label: "Delete group", run: () => { if (window.confirm(`Delete group "${group.name}"? Its tasks are removed; sessions return to Unsorted.`)) commit(mut.deleteGroup(group.id), () => api("group-delete", { groupId: group.id })); } }
|
|
]); },
|
|
children: [
|
|
ICON.chevron(open),
|
|
e("span", { key: "i", className: "dpt__ico" }, ICON.group()),
|
|
e("span", { key: "l", className: "dpt__label" }, group.name),
|
|
e("span", { key: "a", className: "dpt__actions" }, [
|
|
actBtn(ICON.plus(), "New task", async () => { const n = promptName("New task name"); if (n) await run(api("task-create", { groupId: group.id, name: n })); })
|
|
])
|
|
]
|
|
}),
|
|
open && e("div", { key: "c", className: "dpt__children", ...dropAttrs({ type: "group", id: group.id, projectId: group.projectId }) }, group.tasks.map((t) => taskNode(t)))
|
|
]);
|
|
};
|
|
|
|
const projectNode = (project) => {
|
|
const key = "p:" + project.id;
|
|
const open = !isCollapsed(key);
|
|
return e(react.Fragment, { key: project.id }, [
|
|
row({
|
|
id: project.id, dragType: "project", drag: true,
|
|
drop: dropAttrs({ type: "project", id: project.id }),
|
|
onClick: () => toggle(key),
|
|
onContextMenu: (ev) => { lastMenuEvent.current = ev; openMenu(ev, [
|
|
{ icon: ICON.plus(), label: "New group…", run: async () => { const n = promptName("New group name"); if (n) await run(api("group-create", { projectId: project.id, name: n })); } },
|
|
{ icon: ICON.edit(), label: "Rename project", run: () => { const n = promptName("Rename project", project.name); if (n) commit(mut.renameProject(project.id, n), () => api("project-rename", { projectId: project.id, name: n })); } },
|
|
{ icon: ICON.trash(), label: "Delete project", run: () => { if (window.confirm(`Delete project "${project.name}"? Its groups and tasks are removed; sessions return to Unsorted.`)) commit(mut.deleteProject(project.id), () => api("project-delete", { projectId: project.id })); } }
|
|
]); },
|
|
children: [
|
|
ICON.chevron(open),
|
|
e("span", { key: "i", className: "dpt__ico" }, ICON.project()),
|
|
e("span", { key: "l", className: "dpt__label" }, project.name),
|
|
e("span", { key: "a", className: "dpt__actions" }, [
|
|
actBtn(ICON.plus(), "New group", async () => { const n = promptName("New group name"); if (n) await run(api("group-create", { projectId: project.id, name: n })); })
|
|
])
|
|
]
|
|
}),
|
|
open && e("div", { key: "c", className: "dpt__children", ...dropAttrs({ type: "project", id: project.id }) }, project.groups.map((g) => groupNode(g)))
|
|
]);
|
|
};
|
|
|
|
const renameSession = (sessionId) => {
|
|
const current = list.byId[sessionId]?.displayTitle ?? "";
|
|
const n = promptName("Rename session", current);
|
|
if (n && n !== current) A.renameSession(sessionId, n).then((r) => { if (r && r.ok === false) flash(r.error); });
|
|
};
|
|
|
|
const sessionMenuItems = (sessionId) => {
|
|
const items = [{ icon: ICON.edit(), label: "Rename session", run: () => renameSession(sessionId) }, { separator: true }];
|
|
for (const project of view.projects) for (const group of project.groups) for (const task of group.tasks) {
|
|
items.push({ icon: ICON.task(), label: task.name, hint: project.name + " / " + group.name, run: () => commit(mut.assign(sessionId, task.id), () => api("session-assign", { sessionId, taskId: task.id })) });
|
|
}
|
|
if (items.length > 0) items.push({ separator: true });
|
|
items.push({ label: "Remove from task (Unsorted)", run: () => commit(mut.assign(sessionId, null), () => api("session-assign", { sessionId, taskId: null })) });
|
|
return items;
|
|
};
|
|
|
|
const unsortedKey = "unsorted";
|
|
const unsortedOpen = !isCollapsed(unsortedKey);
|
|
|
|
const menuNode = menu === null ? null : e("div", { className: "dpt__menu", style: { left: Math.min(menu.x, window.innerWidth - 210), top: Math.min(menu.y, window.innerHeight - 40) }, onMouseDown: (ev) => ev.stopPropagation() },
|
|
menu.items.map((item, index) => item.separator
|
|
? e("div", { key: "s" + index, className: "dpt__sep" })
|
|
: e("div", { key: index, className: "dpt__mi", onClick: () => { closeMenu(); void item.run(); } }, [
|
|
item.icon ? e("span", { key: "i", className: "dpt__ico" }, item.icon) : null,
|
|
e("span", { key: "l", style: { overflow: "hidden", textOverflow: "ellipsis" } }, item.label),
|
|
item.hint ? e("small", { key: "h" }, item.hint) : null
|
|
])
|
|
));
|
|
|
|
const workItems = [];
|
|
for (const id of (tree?.inWork ?? [])) {
|
|
const s = list.byId[id];
|
|
if (s && sessionVisible(s, archived)) {
|
|
workItems.push({ id, name: s.displayTitle || "Untitled", icon: e("span", { className: "dpt__dot", "data-state": sessionState(s) }) });
|
|
}
|
|
}
|
|
const workSection = workItems.length === 0 ? null : e(react.Fragment, { key: "work" }, [
|
|
e("div", { key: "wb", className: "dpt__bar" }, [e("b", { key: "t" }, "In work")]),
|
|
workItems.map((it) => e("div", { key: it.id, className: "dpt__row", "data-active": it.id === list.current, onClick: () => A.openSession(it.id), onContextMenu: (ev) => { lastMenuEvent.current = ev; openMenu(ev, sessionMenuItems(it.id)); } }, [
|
|
e("span", { key: "t", className: "dpt__twist" }),
|
|
e("div", { key: "body", className: "dpt__body" }, [
|
|
e("span", { key: "i", className: "dpt__ico", "data-kind": "session" }, it.icon),
|
|
e("span", { key: "l", className: "dpt__label", title: it.name }, it.name),
|
|
workToggle(it.id)
|
|
])
|
|
]))
|
|
]);
|
|
|
|
return e("div", { className: "dpt" }, [
|
|
notice && e("div", { key: "notice", className: "dpt__notice" }, notice),
|
|
workSection,
|
|
e("div", { key: "bar", className: "dpt__bar" }, [
|
|
e("b", { key: "t" }, "Projects"),
|
|
e("span", { key: "add", className: "dpt__iconbtn", title: "New project", onClick: async () => { const n = promptName("New project name"); if (n) await run(api("project-create", { name: n })); } }, ICON.plus())
|
|
]),
|
|
tree === null
|
|
? e("div", { key: "hint", className: "dpt__hint" }, "Loading…")
|
|
: e(react.Fragment, { key: "tree" }, [
|
|
view.projects.map((p) => projectNode(p)),
|
|
e(react.Fragment, { key: "uns" }, [
|
|
row({
|
|
id: unsortedKey, dragType: "_", drag: false,
|
|
drop: dropAttrs({ type: "unsorted" }),
|
|
onClick: () => toggle(unsortedKey),
|
|
children: [
|
|
ICON.chevron(unsortedOpen),
|
|
e("span", { key: "i", className: "dpt__ico" }, ICON.group()),
|
|
e("span", { key: "l", className: "dpt__label", style: { color: "var(--dsw-alias-label-tertiary)" } }, "Unsorted")
|
|
]
|
|
}),
|
|
unsortedOpen && e("div", { key: "c", className: "dpt__children", ...dropAttrs({ type: "unsorted" }) }, view.unsorted.map((s) => sessionRow(s)))
|
|
])
|
|
]),
|
|
menuNode
|
|
]);
|
|
}
|
|
|
|
function apply(ctx) {
|
|
const injected = {
|
|
openSession: (sessionId) => { ctx.sessions.open(sessionId); },
|
|
startSession: (workspaceId) => ctx.workspaces.startSession(workspaceId),
|
|
renameSession: async (sessionId, title) => {
|
|
const session = ctx.sessions.binding(sessionId)?.session;
|
|
if (session === undefined) return { ok: false, error: { code: "unknown-session" } };
|
|
const result = await session.rename(title);
|
|
return result?.ok === true ? { ok: true } : { ok: false, error: result?.error ?? { code: "rename-failed" } };
|
|
},
|
|
listTree: () => api("list", {})
|
|
};
|
|
ctx.slots.inject("sidebar.workspaces", () => ctx.slots.register({
|
|
name: "sidebar.workspaces",
|
|
priority: -1,
|
|
store: createViewStore(),
|
|
children: {},
|
|
inject: () => injected
|
|
}, ProjectTreeBrowser));
|
|
}
|
|
|
|
module.exports = { name, inject, apply };
|
|
return module.exports;
|
|
}
|
|
});
|