ouroboros/web/modules/project_create.js
2026-07-14 18:55:54 +00:00

292 lines
15 KiB
JavaScript

// New Project dialog + project row actions (v6.59.0, Phase 3).
//
// One modal, four sources (quiz-approved UX): file-less | genesis (fresh managed
// folder) | attach an existing folder (server-side directory browser — works in
// web/Docker where no native picker exists; optional init_git attach-snapshot)
// | clone a git URL (server-side, typed auth_required). The dialog carries the
// honest trust line: attaching gives the agent write+shell in that folder
// (notification model — attaching IS the grant; no second confirmation).
import { openConfirmDialog } from './confirm_dialog.js';
// Mirrored from the frozen backend PROJECT_NAME_MAX contract.
const PROJECT_NAME_MAX = 80;
export function openNewProjectDialog({ apiClient, onCreated }) {
const maxNameLength = PROJECT_NAME_MAX;
return new Promise((resolve) => {
const backdrop = document.createElement('div');
backdrop.className = 'marketplace-modal-backdrop new-project-backdrop';
backdrop.innerHTML = `
<div class="marketplace-modal new-project-dialog" role="dialog" aria-modal="true" aria-labelledby="new-project-title">
<div class="marketplace-modal-head">
<h3 id="new-project-title">New Project</h3>
<button type="button" class="btn btn-default btn-sm" data-np-cancel aria-label="Close">Close</button>
</div>
<div class="marketplace-modal-body">
<label class="new-project-field">
<span>Name</span>
<input class="files-modal-input" data-np-name type="text" placeholder="My project" maxlength="${maxNameLength}">
</label>
<fieldset class="new-project-sources">
<legend>Working folder</legend>
<label><input type="radio" name="np-source" value="fileless" checked> None — chat/research project (no folder)</label>
<label><input type="radio" name="np-source" value="genesis"> New managed folder (fresh git repo under the projects root)</label>
<label><input type="radio" name="np-source" value="attach"> Attach an existing folder…</label>
<label><input type="radio" name="np-source" value="clone"> Clone a git URL…</label>
</fieldset>
<div class="new-project-source-detail" data-np-attach hidden>
<div class="new-project-browser">
<div class="new-project-browser-path" data-np-path></div>
<div class="new-project-browser-list" data-np-dirs></div>
</div>
<label class="local-toggle" title="Runs git init + an 'attach snapshot' commit of the current state with a local identity. Never done without this checkbox.">
<input type="checkbox" data-np-initgit>
Initialize git here if missing (attach-snapshot commit)
</label>
</div>
<div class="new-project-source-detail" data-np-clone hidden>
<label class="new-project-field">
<span>Git URL</span>
<input class="files-modal-input" data-np-giturl type="text" placeholder="https://github.com/user/repo.git or git@github.com:user/repo.git">
</label>
</div>
<p class="new-project-trust-note" data-np-trust hidden>
The agent gets <strong>read, write and shell</strong> in this folder when working on this project's tasks.
</p>
<p class="new-project-error" data-np-error hidden></p>
</div>
<div class="marketplace-modal-actions">
<button type="button" class="btn btn-default" data-np-cancel>Cancel</button>
<button type="button" class="btn btn-primary" data-np-create>Create project</button>
</div>
</div>
`;
const q = (sel) => backdrop.querySelector(sel);
let selectedDir = '';
let browsePath = '';
const setError = (text) => {
const el = q('[data-np-error]');
el.textContent = text || '';
el.hidden = !text;
};
async function renderDirs(path) {
const listEl = q('[data-np-dirs]');
const pathEl = q('[data-np-path]');
listEl.textContent = 'Loading…';
try {
const data = await apiClient.fsDirs(path);
browsePath = data.path;
pathEl.textContent = '';
const cur = document.createElement('strong');
cur.textContent = data.path;
pathEl.appendChild(cur);
const select = document.createElement('button');
select.type = 'button';
select.className = 'btn btn-sm ' + (selectedDir === data.path ? 'btn-primary' : 'btn-default');
select.textContent = selectedDir === data.path ? 'Selected ✓' : 'Select this folder';
select.addEventListener('click', () => {
selectedDir = data.path;
q('[data-np-trust]').hidden = false;
renderDirs(data.path);
});
pathEl.appendChild(select);
listEl.textContent = '';
if (data.parent) {
const up = document.createElement('button');
up.type = 'button';
up.className = 'new-project-dir-row new-project-dir-up';
up.textContent = '.. (up)';
up.addEventListener('click', () => renderDirs(data.parent));
listEl.appendChild(up);
}
for (const dir of data.dirs || []) {
const row = document.createElement('button');
row.type = 'button';
row.className = 'new-project-dir-row';
row.textContent = `${dir.name}${dir.is_git ? ' ⎇' : ''}`;
row.title = dir.path + (dir.is_git ? ' (git repository)' : '');
row.addEventListener('click', () => renderDirs(dir.path));
listEl.appendChild(row);
}
if (!(data.dirs || []).length) {
const empty = document.createElement('div');
empty.className = 'new-project-dir-empty';
empty.textContent = '(no subfolders)';
listEl.appendChild(empty);
}
if (data.truncated) {
const more = document.createElement('div');
more.className = 'new-project-dir-empty';
more.textContent = '(more folders exist — showing the first 500)';
listEl.appendChild(more);
}
} catch (e) {
listEl.textContent = `Cannot browse: ${e?.message || e}`;
}
}
function syncSource() {
const source = backdrop.querySelector('input[name="np-source"]:checked')?.value || 'fileless';
q('[data-np-attach]').hidden = source !== 'attach';
q('[data-np-clone]').hidden = source !== 'clone';
q('[data-np-trust]').hidden = !(
(source === 'attach' && selectedDir) || source === 'clone' || source === 'genesis'
);
if (source === 'attach' && !browsePath) renderDirs('');
}
backdrop.querySelectorAll('input[name="np-source"]').forEach((radio) => {
radio.addEventListener('change', syncSource);
});
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
document.removeEventListener('keydown', onKey);
backdrop.remove();
resolve(value);
};
const onKey = (event) => {
if (event.key === 'Escape') finish(null);
};
document.addEventListener('keydown', onKey);
async function create() {
setError('');
const name = (q('[data-np-name]').value || '').trim();
const source = backdrop.querySelector('input[name="np-source"]:checked')?.value || 'fileless';
if (!name) { setError('Give the project a name.'); return; }
const payload = { name };
if (source === 'genesis') payload.with_workspace = true;
if (source === 'attach') {
if (!selectedDir) { setError('Pick a folder to attach (Select this folder).'); return; }
payload.path = selectedDir;
payload.init_git = Boolean(q('[data-np-initgit]').checked);
}
if (source === 'clone') {
const url = (q('[data-np-giturl]').value || '').trim();
if (!url) { setError('Enter the git URL to clone.'); return; }
payload.git_url = url;
}
const btn = q('[data-np-create]');
btn.disabled = true;
btn.textContent = source === 'clone' ? 'Cloning…' : 'Creating…';
try {
const data = await apiClient.projectCreate(payload);
if (data?.error) throw new Error(data.error);
finish(data?.project || null);
onCreated?.(data?.project || null);
} catch (e) {
const detail = e?.body?.error || e?.message || String(e);
const code = e?.body?.error_code || '';
setError(code === 'auth_required'
? `This repository needs credentials (private repo). Set up git access on this machine, then retry. Detail: ${detail}`
: detail);
btn.disabled = false;
btn.textContent = 'Create project';
}
}
backdrop.addEventListener('click', (event) => {
if (event.target === backdrop || event.target.closest('[data-np-cancel]')) finish(null);
else if (event.target.closest('[data-np-create]')) create();
});
document.body.appendChild(backdrop);
q('[data-np-name]').focus();
});
}
let closeOpenProjectRowMenu = null;
// Accessible row menu: Rename/Delete only. `project` is a sidebar read model;
// callbacks refresh the authoritative registry projection.
export async function openProjectRowMenu(project, { apiClient, anchorEl, onChanged }) {
const maxNameLength = PROJECT_NAME_MAX;
closeOpenProjectRowMenu?.();
const menu = document.createElement('div');
menu.className = 'project-row-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', `Actions for ${project.name || project.id}`);
menu.innerHTML = `
<button type="button" role="menuitem" data-prm="rename">Rename…</button>
<button type="button" role="menuitem" class="danger" data-prm="delete">Delete project…</button>
`;
const rect = anchorEl.getBoundingClientRect();
const close = ({ restoreFocus = false } = {}) => {
menu.remove();
document.removeEventListener('click', onDoc, true);
document.removeEventListener('keydown', onKey, true);
if (closeOpenProjectRowMenu === close) closeOpenProjectRowMenu = null;
if (restoreFocus && anchorEl.isConnected) anchorEl.focus();
};
const onDoc = (event) => { if (!menu.contains(event.target)) close(); };
const onKey = (event) => {
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
const index = items.indexOf(document.activeElement);
if (event.key === 'Escape') {
event.preventDefault();
close({ restoreFocus: true });
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const delta = event.key === 'ArrowDown' ? 1 : -1;
items[(index + delta + items.length) % items.length]?.focus();
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
items[event.key === 'Home' ? 0 : items.length - 1]?.focus();
}
};
closeOpenProjectRowMenu = close;
document.addEventListener('click', onDoc, true);
document.addEventListener('keydown', onKey, true);
menu.addEventListener('click', async (event) => {
const action = event.target.closest('[data-prm]')?.dataset?.prm;
if (!action) return;
close();
if (action === 'rename') {
const res = await openConfirmDialog({
title: 'Rename project',
body: `New name for “${project.name || project.id}”:`,
input: true,
initialValue: project.name || project.id,
confirmLabel: 'Rename',
});
const newName = res?.confirmed ? String(res.value || '').trim() : '';
if (newName.length > maxNameLength) {
alert(`Project names are limited to ${maxNameLength} characters.`);
} else if (newName && newName !== project.name) {
try { await apiClient.projectUpdate(project.id, newName); onChanged?.(); }
catch (e) { alert(`Rename failed: ${e?.body?.error || e?.message || e}`); }
}
if (anchorEl.isConnected) anchorEl.focus();
} else if (action === 'delete') {
const ok = await openConfirmDialog({
title: 'Delete project',
body: `Delete “${project.name || project.id}”? Running work will be cancelled. The Project will be removed from the active UI; its id, chat history, task bindings, memory, and working folder are preserved.`,
confirmLabel: 'Delete',
danger: true,
});
if (ok === true) {
onChanged?.({ projectId: project.id, lifecycle: 'deleting', optimistic: true });
try { await apiClient.projectDelete(project.id); }
catch (e) { alert(`Delete did not finish: ${e?.body?.error || e?.message || e}`); }
finally { onChanged?.({ authoritative: true }); }
}
if (anchorEl.isConnected) anchorEl.focus();
}
});
document.body.appendChild(menu);
const menuRect = menu.getBoundingClientRect();
const margin = 8;
const top = Math.min(
Math.max(margin, rect.bottom + 4),
Math.max(margin, window.innerHeight - menuRect.height - margin),
);
const left = Math.min(
Math.max(margin, rect.right - menuRect.width),
Math.max(margin, window.innerWidth - menuRect.width - margin),
);
menu.style.setProperty('--prm-top', `${Math.round(top)}px`);
menu.style.setProperty('--prm-left', `${Math.round(left)}px`);
menu.querySelector('[role="menuitem"]')?.focus();
}