Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/design/missing-project-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Missing-project detection + re-clone offer

## Цель
Когда зарегистрированный проект удалён/перемещён на диске, Projects-лаунчер должен
это заметить: показать дисклеймер на плитке и, при попытке запуска, вернуть понятную
ошибку «папка отсутствует» с предложением заново скачать актуальную версию с GitHub.

## Проблема
`projects.json` хранит путь проекта. Если пользователь удалил репо (`rm -rf`),
плитка остаётся, а запуск (`POST /api/launch`) падает с общей ошибкой
`invalid or unsafe project path` — пользователь не понимает причину.

## Данные
- Реестр: `~/.codedash/projects.json` — `{ id, name, path, source, remoteUrl, defaultBranch }`.
- Признак существования на диске выводится динамически (`fs.statSync().isDirectory()`),
в файл не пишется — состояние диска может меняться между запросами.

## Изменения API
- `GET /api/projects/manual` — к каждому проекту добавляется `exists: boolean`.
`git` вычисляется только когда `exists === true`.
- `POST /api/launch` — если `project` передан и папки нет на диске, вернуть
`400 { ok:false, error:'project folder is missing on disk', missing:true,
remoteUrl, projectId }` вместо общей ошибки. Проверка идёт до `isSafeLaunchPath`,
чтобы отличить «удалено» от «небезопасный путь».
- `POST /api/projects/reclone` — новый маршрут. Тело `{ id }`. Находит проект в
реестре, требует непустой `remoteUrl`, клонирует `remoteUrl` в **исходный** `path`
(не в свежий `~/code/<repo>`), чтобы восстановить папку ровно там, где она была.
Переиспользует `cloneRepo` (та же защита: только GitHub-remote, назначение под `$HOME`,
существующий-тот-же-repo → успех).

## Стыки
- `src/projects.js` — новый экспорт `pathExists(p)`; `cloneRepo` переиспользуется.
- `src/server.js` — GET manual enrich, launch guard, новый reclone-маршрут.
- `src/frontend/app.js` — `mergeRegistryWithSessions` пробрасывает `_exists`/`_remoteUrl`;
`renderLauncherCard` рисует missing-состояние; `recloneProject()`; launch-функции
обрабатывают `data.missing`.
- `src/frontend/styles.css` — `.launcher-card-missing`, `.launcher-card-warning`.

## UX & Accessibility
**Required UI states:**
- [x] Normal — папка на месте: обычные кнопки запуска.
- [x] Missing — папка удалена: дисклеймер `role="status"`, кнопки «Re-clone» (если есть
GitHub-remote) и «Remove»; кнопки запуска скрыты.
- [x] Loading — кнопка Re-clone: `disabled` + текст «Cloning…».
- [x] Error — reclone/launch fail: toast с текстом ошибки, кнопка возвращается в исходное.
- [x] Success — toast «Re-cloned … from GitHub», перезагрузка реестра → плитка снова обычная.

**Keyboard/SR:** дисклеймер — `role="status"` (объявляется screen reader'ом); кнопки
имеют `aria-label`; фокус-ринг наследуется от `.git-project-launch-btn:focus-visible`.

## Риски
- TOCTOU (папку удалили между рендером и кликом) — `addressed_in`: launch-guard в
`/api/launch` возвращает `missing:true`; `handleMissingProjectLaunch` предлагает reclone.
- `missing:true` глобален для `/api/launch`, поэтому обрабатывается на ВСЕХ трёх точках
запуска — `addressed_in`: `app.js launchNewProjectSession`/`resumeLastProjectSession` и
`detail.js launchSession` (session-detail «Resume»).
- Symlink-ancestor обход home-containment в `cloneRepo` (окно «папка отсутствует») —
`addressed_in`: `projects.js realpathOfNearestAncestor` + `isUnderHome` перед `git clone`.
- reclone для manual-проекта вне `$HOME` или с non-GitHub remote — `cloneRepo` вернёт
понятную ошибку, показываем toast (`addressed`: сообщение об ошибке).
- Проект без `remoteUrl` (локальная папка) — кнопка Re-clone не рисуется, только Remove.
- Параллельные reclone одного id — `addressed_in`: `_inFlightReclone` Set в `server.js` (409).
- HTTP-уровневые тесты новых маршрутов (`/api/launch missing`, `/api/projects/reclone`) —
`deferred_to`: follow-up. `startServer` не имеет teardown-seam (интервалы autoSync/heartbeat
держат event loop), поэтому route-тест требует рефактора извлечения маршрутов. Interim
evidence: scratchpad smoke (register → delete → `exists:false` → `missing:true` → reclone
guardrails) — GREEN. Unit-тесты `pathExists`/`cloneRepo` покрыты.
- Systemic (pre-existing, не в этом PR): mutating POST-маршруты не проверяют `Origin` —
`deferred_to`: отдельный follow-up (repo-wide CSRF hardening).
50 changes: 50 additions & 0 deletions specs/missing-project-detection.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
Feature: Missing-project detection and re-clone offer on the Projects launcher

Background:
Given a project "my-repo" is registered with remoteUrl "https://github.com/me/my-repo.git"

Scenario: Happy path — folder present renders normal launch controls
Given the folder for "my-repo" exists on disk
When I open the Projects landing
Then the tile shows the ▶ New / Last / Terminal launch controls
And no "folder is missing" disclaimer is shown

Scenario: Empty/missing state — deleted folder shows disclaimer
Given the folder for "my-repo" was deleted from disk
When I open the Projects landing
Then the tile is marked as missing
And a disclaimer says the folder is missing and can be re-cloned from GitHub
And the ▶ New / Last launch controls are hidden
And a "Re-clone" and a "Remove" button are shown

Scenario: Loading state — re-clone in progress
Given the folder for "my-repo" is missing
When I click "Re-clone"
Then the button is disabled and shows "Cloning…"

Scenario: Success — re-clone restores the folder and normal controls
Given the folder for "my-repo" is missing
When I click "Re-clone" and the clone succeeds
Then a toast confirms the folder was re-cloned from GitHub
And the registry is refreshed and the tile returns to the normal launch state

Scenario: Error — launch of a deleted folder returns an actionable response
Given the folder for "my-repo" was deleted after the page loaded
When I click ▶ New on the tile
Then the server responds 400 with missing=true and the project's remoteUrl
And the UI tells me the folder is missing and offers to re-clone it

Scenario: Negative — local project without a GitHub remote cannot be re-cloned
Given a project "local-only" is registered with no remoteUrl
And its folder is missing on disk
When I open the Projects landing
Then the disclaimer tells me to restore the folder or remove it from the list
And no "Re-clone" button is shown, only "Remove"

Scenario: Edge — re-clone into a path outside the home directory fails cleanly
Given a project whose stored path is outside the home directory is missing
When I click "Re-clone"
Then a toast shows the clone error and the button returns to "Re-clone"

# N/A: keyboard-only — reuses existing .git-project-launch-btn focus-ring and aria-labels;
# no new focus-trap or shortcut introduced.
143 changes: 135 additions & 8 deletions src/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2075,6 +2075,14 @@ function scrollToInstallAgents() {
if (section && section.scrollIntoView) section.scrollIntoView({ behavior: 'smooth', block: 'center' });
}

// Anchored GitHub-remote check, matching the server's cloneRepo regex
// (src/projects.js). Used to decide whether to offer "Re-clone" — an unanchored
// substring test would show the button for a spoofed URL like
// https://evil.com/github.com/... that the server would then reject.
function isGithubRemote(url) {
return typeof url === 'string' && /^(https:\/\/github\.com\/|git@github\.com:)/.test(url);
}

// Live Workspace panes whose resolved cwd is this project folder.
function _projectLiveTerminals(projPath) {
if (!projPath || typeof _wsAllPanes !== 'function') return [];
Expand All @@ -2100,9 +2108,15 @@ function renderLauncherCard(projKey, projInfo) {

var preferredTool = pickPreferredTool(projPath, lastSession);
var installed = window.installedAgents || [];
var canLaunch = installed.length > 0 && !!projPath;

var html = '<div class="launcher-card">';
// A registered folder can be deleted from disk at any time; `_exists === false`
// comes from the server's on-disk check. When missing, we suppress the launch
// controls (they'd fail) and surface a re-clone/remove path instead.
var exists = projInfo._exists !== false;
var remoteUrl = projInfo._remoteUrl || '';
var canReclone = !exists && isGithubRemote(remoteUrl);
var canLaunch = installed.length > 0 && !!projPath && exists;

var html = '<div class="launcher-card' + (exists ? '' : ' launcher-card-missing') + '">';
html += '<div class="launcher-card-header">';
html += '<span class="launcher-card-dot" style="background:' + color + '"></span>';
html += '<span class="launcher-card-name" title="' + escHtml(projName) + '">' + escHtml(projName) + '</span>';
Expand All @@ -2111,10 +2125,46 @@ function renderLauncherCard(projKey, projInfo) {
if (projPath) html += '<div class="launcher-card-path" title="' + escHtml(projPath) + '">' + escHtml(projPath) + '</div>';
html += '<div class="launcher-card-meta">';
html += '<span>' + (totalSessions === 0 ? 'no sessions yet' : (totalSessions + ' session' + (totalSessions === 1 ? '' : 's'))) + '</span>';
if (preferredTool) html += '<span>· next: ' + escHtml(agentLabel(preferredTool)) + '</span>';
if (exists && preferredTool) html += '<span>· next: ' + escHtml(agentLabel(preferredTool)) + '</span>';
html += '</div>';

html += '<div class="launcher-card-actions">';
// Disclaimer for a deleted/moved folder — persistent descriptive content, so
// role="note" (not a live region: it's present on render, not a transient
// status update — the launch-fail case is announced via toast instead).
if (!exists) {
html += '<div class="launcher-card-warning" role="note">' +
'⚠ Folder is missing on disk — it was moved or deleted. ' +
(canReclone
? 'Re-clone the latest version from GitHub.'
: 'Restore the folder, or remove it from the list.') +
'</div>';
}

if (!exists) {
// Missing folder: no launch controls. Offer Re-clone (when we have a GitHub
// remote) and Remove-from-registry. Only emit the actions row if at least
// one control will render, so an edge-case card isn't left with an empty box.
var missingActions = '';
if (canReclone) {
var recloneAria = 'Re-clone ' + projName + ' from GitHub';
missingActions += '<button class="git-project-launch-btn primary reclone-btn" ' +
'data-proj-id="' + escHtml(projInfo.manualId || '') + '" data-proj-name="' + escHtml(projName) + '" ' +
'onclick="recloneProject(this.dataset.projId, this.dataset.projName, this)" ' +
'title="' + escHtml('Re-download the latest version from GitHub into ' + projPath) + '" ' +
'aria-label="' + escHtml(recloneAria) + '">↓ Re-clone</button>';
}
if (projInfo.manualId) {
missingActions += '<button class="git-project-launch-btn" data-proj-id="' + escHtml(projInfo.manualId) + '" data-proj-name="' + escHtml(projName) + '" onclick="unregisterProject(this.dataset.projId,this.dataset.projName)" title="Remove from registry (does not delete files)" aria-label="Remove ' + escHtml(projName) + ' from the list">× Remove</button>';
}
if (missingActions) html += '<div class="launcher-card-actions">' + missingActions + '</div>';
// Keep History drill-in available even when the folder is gone (sessions
// live in the agent history dirs, not the repo).
if (totalSessions > 0) {
html += '<button class="launcher-card-link" data-proj-key="' + escHtml(projKey) + '" data-proj-name="' + escHtml(projName) + '" onclick="viewProjectInHistory(this.dataset.projKey,this.dataset.projName)">View ' + totalSessions + ' session' + (totalSessions === 1 ? '' : 's') + ' →</button>';
}
html += '</div>';
return html;
}
if (canLaunch && preferredTool) {
var newAria = 'Start new ' + agentLabel(preferredTool) + ' session in ' + projName;
var pickerAria = 'Pick a different agent for ' + projName;
Expand Down Expand Up @@ -2190,8 +2240,11 @@ function mergeRegistryWithSessions(sessions) {
byGit[info.key].list.push(s);
});
(window.manualProjects || []).forEach(function(p) {
// `exists` is undefined for older payloads / session-derived merges — treat
// absence as "present" so we never falsely flag a folder as missing.
var exists = p.exists !== false;
if (!byGit[p.path]) {
byGit[p.path] = { name: p.name, list: [], path: p.path, source: p.source || 'manual', manualId: p.id, _git: p.git, _lastAdded: p.addedAt };
byGit[p.path] = { name: p.name, list: [], path: p.path, source: p.source || 'manual', manualId: p.id, _git: p.git, _lastAdded: p.addedAt, _exists: exists, _remoteUrl: p.remoteUrl || '' };
} else {
// When a registry entry overlaps a session-derived entry, the registry's
// `source` (manual / github-clone / auto) is the authoritative one — only
Expand All @@ -2203,7 +2256,7 @@ function mergeRegistryWithSessions(sessions) {
var resolvedSource = keepRegistrySource
? p.source
: ((!existing.source || existing.source === 'session') ? 'manual' : existing.source);
byGit[p.path] = { ...existing, manualId: p.id, source: resolvedSource };
byGit[p.path] = { ...existing, manualId: p.id, source: resolvedSource, _exists: exists, _remoteUrl: p.remoteUrl || existing._remoteUrl || '' };
}
});
return byGit;
Expand Down Expand Up @@ -2979,7 +3032,13 @@ document.addEventListener('keydown', function(e) {
return;
}
if (e.key === 'Escape') {
if (pendingDelete) {
// Close the confirm overlay whenever it's on screen — it's shared by the
// delete dialog (sets pendingDelete) AND the "project folder is missing"
// re-clone dialog (does not), so keying off pendingDelete alone left the
// latter undismissable.
var confirmOverlay = document.getElementById('confirmOverlay');
var confirmOpen = confirmOverlay && confirmOverlay.style.display === 'flex';
if (pendingDelete || confirmOpen) {
closeConfirm();
} else {
closeDetail();
Expand Down Expand Up @@ -3713,6 +3772,8 @@ async function launchNewProjectSession(projectPath, tool, btn) {
window.codbashSettings.lastUsedByPath = window.codbashSettings.lastUsedByPath || {};
window.codbashSettings.lastUsedByPath[projectPath] = t;
}
} else if (data.missing) {
handleMissingProjectLaunch(data, projectPath.split('/').pop());
} else {
showToast('Launch failed: ' + (data.error || 'unknown'));
}
Expand Down Expand Up @@ -4028,6 +4089,7 @@ async function resumeLastProjectSession(sessionId, tool, projectPath, btn) {
});
var data = await resp.json();
if (data.ok) showToast('Resuming ' + sessionId.slice(0, 8) + '…');
else if (data.missing) handleMissingProjectLaunch(data, (projectPath || '').split('/').pop());
else showToast('Resume failed: ' + (data.error || 'unknown'));
} catch (e) {
showToast('Resume failed: ' + e.message);
Expand Down Expand Up @@ -4458,6 +4520,71 @@ async function cloneRepoAndAdd(btn) {
}
}

// Re-clone a registered project whose folder was deleted, restoring it at its
// original path. Driven by the "Re-clone" button on a missing launcher card and
// by the re-clone confirm offered after a launch hits a missing folder.
async function recloneProject(id, name, btn) {
if (!id) { showToast('Missing project id'); return; }
var safeName = name || 'project';
if (btn) {
btn.disabled = true;
btn.setAttribute('aria-busy', 'true');
btn.innerHTML = '↓ Cloning…';
} else {
// Driven from the confirm dialog (no button to relabel) — give immediate
// feedback so the multi-second clone isn't silent.
showToast('Cloning ' + safeName + ' from GitHub…');
}
try {
var resp = await fetch('/api/projects/reclone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: id }),
});
var data = await resp.json();
if (data.ok) {
showToast(data.alreadyExisted ? 'Folder already present for ' + safeName : 'Re-cloned ' + safeName + ' from GitHub');
await loadManualProjects();
} else {
if (btn) { btn.disabled = false; btn.removeAttribute('aria-busy'); btn.innerHTML = '↓ Retry'; }
showToast('Re-clone failed: ' + (data.error || 'unknown'));
}
} catch (e) {
if (btn) { btn.disabled = false; btn.removeAttribute('aria-busy'); btn.innerHTML = '↓ Retry'; }
showToast('Re-clone failed: ' + (e && e.message));
}
}

// Shared handler for a launch that failed because the project folder is gone.
// Refreshes the registry (so the tile flips to its missing state) and, when we
// know a GitHub remote, offers a one-click re-clone via the confirm overlay.
function handleMissingProjectLaunch(data, name) {
var safeName = String(name || 'This project').replace(/[\r\n\t\x00-\x1f]/g, ' ').slice(0, 200);
loadManualProjects();
var overlay = document.getElementById('confirmOverlay');
var canReclone = data && data.projectId && isGithubRemote(data.remoteUrl);
if (!canReclone || !overlay) {
// No re-clone possible (or no overlay in the DOM) — a plain toast with the
// recovery hint is the fallback.
showToast('"' + safeName + '" folder is missing on disk — restore it or remove it from Projects');
return;
}
document.getElementById('confirmTitle').textContent = 'Project folder is missing';
document.getElementById('confirmText').textContent =
'"' + safeName + '" was moved or deleted from disk. Re-clone the latest version from GitHub?';
document.getElementById('confirmId').textContent = '';
var btn = document.getElementById('confirmAction');
btn.textContent = 'Re-clone';
btn.className = 'launch-btn btn-primary';
btn.onclick = function() {
overlay.style.display = 'none';
recloneProject(data.projectId, safeName, null);
};
overlay.style.display = 'flex';
// Move focus into the dialog so keyboard/SR users land on the primary action.
setTimeout(function() { if (btn && btn.focus) btn.focus(); }, 0);
}

// ── Initialization ─────────────────────────────────────────────

async function loadAgentsAndSettings() {
Expand Down
Loading
Loading