Compare commits
12 Commits
v1.8.0
...
0f126cf911
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f126cf911 | |||
| b5236b41c8 | |||
| 2506dfb1f3 | |||
| 7d618c30d5 | |||
| 529c136199 | |||
| 7fc1f6f747 | |||
| 8b9060e0c0 | |||
| 06a400acc7 | |||
| a1fd00b046 | |||
| 057c2d55ed | |||
| cf7eb05aca | |||
| 663ae7ace1 |
@@ -4,7 +4,9 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
# `v[0-9]*` (et non `v*`) : sinon ce workflow capture aussi les tags `vscode-v*` de l'extension
|
||||
# — il stripperait alors `v` (→ `scode-v0.1.0`) et échouerait contre la version du daemon.
|
||||
tags: ['v[0-9]*']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
74
.gitea/workflows/vscode-release.yml
Normal file
74
.gitea/workflows/vscode-release.yml
Normal file
@@ -0,0 +1,74 @@
|
||||
# Packaging du VSIX de l'extension VS Code, déclenché UNIQUEMENT par un tag vscode-vX.Y.Z
|
||||
# (séparé de la release du daemon, qui écoute les tags v*). Le .vsix est exposé en artefact du run
|
||||
# (toujours) et, en best-effort, attaché à la release Gitea correspondante.
|
||||
name: VSCode Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['vscode-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Package VSIX
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Garde-fou : le tag (sans "vscode-v") doit correspondre à la version du manifeste de l'extension.
|
||||
- name: Verify tag matches extension version
|
||||
run: |
|
||||
pkg=$(node -p "require('./packages/vscode/package.json').version")
|
||||
tag="${GITHUB_REF_NAME#vscode-v}"
|
||||
if [ "$pkg" != "$tag" ]; then
|
||||
echo "ERREUR: tag '$tag' != version extension '$pkg'"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: tag $tag == version $pkg"
|
||||
# Build du shared puis bundle esbuild de l'extension (typecheck inclus), puis packaging VSIX.
|
||||
# --no-dependencies : tout est bundlé dans dist/extension.js → pas de node_modules dans le VSIX.
|
||||
- name: Build & package
|
||||
run: |
|
||||
npm run build:vscode
|
||||
version=$(node -p "require('./packages/vscode/package.json').version")
|
||||
cd packages/vscode
|
||||
npx --yes @vscode/vsce package --no-dependencies -o "git-arboretum-${version}.vsix"
|
||||
# Artefact du run : canal de distribution fiable, indépendant de l'API release.
|
||||
# upload-artifact@v3 : Gitea Actions ne supporte pas @v4 (@actions/artifact v2+).
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: vsix
|
||||
path: packages/vscode/*.vsix
|
||||
# Best-effort : attache le VSIX à la release Gitea du tag (crée la release si absente).
|
||||
# Réutilise le secret NPM_TOKEN (même token Gitea que la publication du daemon — droits
|
||||
# repository suffisants pour l'API release). Sans lui, l'étape est ignorée sans faire échouer
|
||||
# le job (continue-on-error) ; le VSIX reste disponible en artefact.
|
||||
- name: Attach VSIX to Gitea release
|
||||
continue-on-error: true
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_TOKEN" ]; then
|
||||
echo "::notice::NPM_TOKEN absent — VSIX disponible en artefact uniquement."
|
||||
exit 0
|
||||
fi
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
auth="Authorization: token ${RELEASE_TOKEN}"
|
||||
version=$(node -p "require('./packages/vscode/package.json').version")
|
||||
vsix="packages/vscode/git-arboretum-${version}.vsix"
|
||||
# id de release du tag, sinon création
|
||||
rid=$(curl -fsSL -H "$auth" "${api}/releases/tags/${GITHUB_REF_NAME}" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''" || true)
|
||||
if [ -z "$rid" ]; then
|
||||
rid=$(curl -fsSL -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"Arboretum VSCode ${version}\"}" \
|
||||
"${api}/releases" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''")
|
||||
fi
|
||||
curl -fsSL -X POST -H "$auth" -F "attachment=@${vsix}" \
|
||||
"${api}/releases/${rid}/assets?name=git-arboretum-${version}.vsix"
|
||||
echo "VSIX attaché à la release ${GITHUB_REF_NAME}."
|
||||
21
README.fr.md
21
README.fr.md
@@ -32,6 +32,7 @@ Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanle
|
||||
- **Terminal web** — terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur ; vraiment plein écran, avec l'invite ancrée en bas et tout l'historique défilable au-dessus.
|
||||
- **Supervision depuis votre téléphone** — PWA installable avec notifications push quand une session vous attend ; répondez à une demande (ses options, ou refusez) sans ouvrir de terminal.
|
||||
- **Groupes de travail** — regroupez des repos liés (ex. une API, son frontend web et sa doc) dans un groupe nommé, puis lancez **une seule session Claude qui les couvre tous à la fois** (via le flag `--add-dir` du CLI) : une conversation unique avec un contexte partagé travaillant à travers chaque repo, plus une vue unifiée de tous leurs worktrees et une grille multi-terminaux côte à côte. Une session de groupe peut d'abord créer le même worktree de branche dans chaque repo, ou tourner directement sur les checkouts principaux.
|
||||
- **Extension VS Code** — une extension native (pas un webview) qui amène l'arbre en direct, les terminaux de session natifs, les alertes d'attente et les actions git directement dans votre éditeur. Voir [Extension VS Code](#extension-vs-code).
|
||||
|
||||
---
|
||||
|
||||
@@ -104,6 +105,24 @@ node packages/server/dist/index.js
|
||||
4. **Suivez les états en direct.** Chaque session indique si elle est *busy*, *en attente de votre entrée* ou *idle*. Ouvrez le **terminal web** pour interagir directement ; il survit aux déconnexions du navigateur (fermer l'onglet ne tue pas la session).
|
||||
5. **Supervisez depuis votre téléphone.** Installez la PWA, et quand une session bascule en *attente*, vous recevez une notification push. Répondez à la demande — choisissez l'une de ses options ou refusez-la — directement depuis le dashboard, sans terminal.
|
||||
|
||||
## Extension VS Code
|
||||
|
||||
Vous préférez rester dans votre éditeur ? Arboretum fournit une **extension VS Code native** (`packages/vscode`) — pas un webview. Elle se connecte au même daemon et l'expose avec les primitives natives de VS Code :
|
||||
|
||||
- Un arbre **Repositories** et **Groups** en direct (repos → worktrees → sessions) dans l'Activity Bar, mis à jour en temps réel via le WebSocket du daemon.
|
||||
- **Terminaux natifs** : attachez-vous (ou observez) n'importe quelle session dans un vrai terminal VS Code — vous bénéficiez du rendu, du scrollback et du copier-coller de VS Code gratuitement.
|
||||
- Un compteur en **status bar** et des **notifications** natives quand une session attend, avec réponses Oui/Non sans ouvrir de terminal.
|
||||
- Les mutations git (créer un worktree, commit, push, promouvoir) et la **conscience du workspace** — le worktree de votre dossier ouvert est mis en évidence, avec « démarrer une session / créer un worktree ici » en un clic.
|
||||
|
||||
Elle est distribuée en **VSIX privé**. Buildez-la et packagez-la depuis le monorepo :
|
||||
|
||||
```bash
|
||||
npm run build:vscode
|
||||
cd packages/vscode && npx @vscode/vsce package --no-dependencies # → git-arboretum-0.1.0.vsix
|
||||
```
|
||||
|
||||
Puis installez-la via **Extensions : Installer à partir d'un VSIX…** (ou `code --install-extension git-arboretum-0.1.0.vsix`), lancez **Arboretum: Sign In** et collez un token. Détails complets dans [`packages/vscode/README.md`](packages/vscode/README.md).
|
||||
|
||||
## Accès distant depuis votre téléphone
|
||||
|
||||
Arboretum se bind sur `127.0.0.1` par défaut et **refuse** de se binder sur une adresse non-loopback sans dérogation explicite. La façon recommandée (et sûre) de l'atteindre depuis d'autres appareils est **[Tailscale Serve](https://tailscale.com/kb/1242/tailscale-serve)** — HTTPS valide, identité tailnet, aucun port ouvert :
|
||||
@@ -235,7 +254,7 @@ Arboretum enveloppe le CLI Claude Code **interactif** dans un PTY — la même c
|
||||
|
||||
## Développement
|
||||
|
||||
Arboretum est un monorepo npm workspaces : `@arboretum/shared` (protocole WS/REST, source de vérité), `@johanleroy/git-arboretum` (le daemon Fastify, le paquet publié) et `@arboretum/web` (la SPA Vue 3).
|
||||
Arboretum est un monorepo npm workspaces : `@arboretum/shared` (protocole WS/REST, source de vérité), `@johanleroy/git-arboretum` (le daemon Fastify, le paquet publié), `@arboretum/web` (la SPA Vue 3) et `git-arboretum` (l'extension VS Code — buildée séparément avec `npm run build:vscode`).
|
||||
|
||||
```bash
|
||||
npm run build # build shared → server → web (l'ordre compte)
|
||||
|
||||
21
README.md
21
README.md
@@ -32,6 +32,7 @@ A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arbore
|
||||
- **Web terminal** — full xterm.js terminal to every managed session, surviving browser disconnects; truly fullscreen, with the prompt pinned to the bottom and full scrollback above.
|
||||
- **Supervision from your phone** — installable PWA with push notifications when a session needs you; answer a prompt (its options, or deny) without opening a terminal.
|
||||
- **Work groups** — bundle related repos (e.g. an API, its web frontend and its docs) into a named group, then launch **one Claude session that spans all of them at once** (via the CLI's `--add-dir`): a single conversation with one shared context working across every repo, plus a unified view of all their worktrees and a side-by-side multi-terminal grid. Group sessions can either create the same branch worktree in each repo first, or run straight on the main checkouts.
|
||||
- **VS Code extension** — a native extension (not a webview) that brings the live tree, native session terminals, waiting alerts and git actions right into your editor. See [VS Code extension](#vs-code-extension).
|
||||
|
||||
---
|
||||
|
||||
@@ -104,6 +105,24 @@ node packages/server/dist/index.js
|
||||
4. **Watch the live states.** Each session reports whether it's *busy*, *waiting for your input*, or *idle*. Open the **web terminal** to interact directly; it survives browser disconnects (closing the tab does not kill the session).
|
||||
5. **Supervise from your phone.** Install the PWA, and when a session flips to *waiting* you get a push notification. Answer the prompt — pick one of its options or deny it — straight from the dashboard, no terminal required.
|
||||
|
||||
## VS Code extension
|
||||
|
||||
Prefer to stay in your editor? Arboretum ships a **native VS Code extension** (`packages/vscode`) — not a webview. It connects to the same daemon and surfaces it with VS Code's own primitives:
|
||||
|
||||
- A live **Repositories** and **Groups** tree (repos → worktrees → sessions) in the Activity Bar, updated in real time over the daemon's WebSocket.
|
||||
- **Native terminals**: attach to (or observe) any session in a real VS Code terminal — you get VS Code's rendering, scrollback and copy/paste for free.
|
||||
- A **status-bar** counter and native **notifications** when a session is waiting, with Yes/No answers without opening a terminal.
|
||||
- Git mutations (create worktree, commit, push, promote) and **workspace awareness** — the worktree for your open folder is highlighted, with one-click "start session / create worktree here".
|
||||
|
||||
It is distributed as a **private VSIX**. Build and package it from the monorepo:
|
||||
|
||||
```bash
|
||||
npm run build:vscode
|
||||
cd packages/vscode && npx @vscode/vsce package --no-dependencies # → git-arboretum-0.1.0.vsix
|
||||
```
|
||||
|
||||
Then install it via **Extensions: Install from VSIX…** (or `code --install-extension git-arboretum-0.1.0.vsix`), run **Arboretum: Sign In** and paste a token. Full details in [`packages/vscode/README.md`](packages/vscode/README.md).
|
||||
|
||||
## Remote access from your phone
|
||||
|
||||
Arboretum binds to `127.0.0.1` by default and **refuses** to bind to a non-loopback address without an explicit override. The recommended (and safe) way to reach it from other devices is **[Tailscale Serve](https://tailscale.com/kb/1242/tailscale-serve)** — valid HTTPS, tailnet identity, no open ports:
|
||||
@@ -235,7 +254,7 @@ Arboretum wraps the **interactive** Claude Code CLI in a PTY — the same thing
|
||||
|
||||
## Development
|
||||
|
||||
Arboretum is an npm-workspaces monorepo: `@arboretum/shared` (WS/REST protocol, source of truth), `@johanleroy/git-arboretum` (the Fastify daemon, the published package), and `@arboretum/web` (the Vue 3 SPA).
|
||||
Arboretum is an npm-workspaces monorepo: `@arboretum/shared` (WS/REST protocol, source of truth), `@johanleroy/git-arboretum` (the Fastify daemon, the published package), `@arboretum/web` (the Vue 3 SPA), and `git-arboretum` (the VS Code extension — built separately with `npm run build:vscode`).
|
||||
|
||||
```bash
|
||||
npm run build # build shared → server → web (order matters)
|
||||
|
||||
3464
package-lock.json
generated
3464
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,9 @@
|
||||
"dev:web": "npm run dev -w @arboretum/web",
|
||||
"build:site": "npm run build -w @arboretum/site",
|
||||
"dev:site": "npm run dev -w @arboretum/site",
|
||||
"preview:site": "npm run preview -w @arboretum/site"
|
||||
"preview:site": "npm run preview -w @arboretum/site",
|
||||
"build:vscode": "npm run build -w @arboretum/shared -w git-arboretum",
|
||||
"dev:vscode": "npm run dev -w git-arboretum"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@johanleroy/git-arboretum",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.0",
|
||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PushService } from './core/push-service.js';
|
||||
import { loadSecretBox } from './core/secret-box.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerProjectRoutes } from './routes/projects.js';
|
||||
import { registerRepoRoutes } from './routes/repos.js';
|
||||
import { registerGroupRoutes } from './routes/groups.js';
|
||||
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||
@@ -170,6 +171,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
||||
registerSessionRoutes(app, manager, discovery, db);
|
||||
registerProjectRoutes(app, manager, db);
|
||||
registerRepoRoutes(app, worktrees, db);
|
||||
registerGroupRoutes(app, groups, db, worktrees, manager);
|
||||
registerWorktreeRoutes(app, worktrees, db);
|
||||
|
||||
@@ -122,8 +122,13 @@ function quoteIfNeeded(token: string): string {
|
||||
return /\s/.test(token) ? `"${token}"` : token;
|
||||
}
|
||||
|
||||
export function renderSystemdUnit(input: { exec: string; scriptArgs: string[] }): string {
|
||||
export function renderSystemdUnit(input: { exec: string; scriptArgs: string[]; pathEnv?: string | undefined }): string {
|
||||
const execStart = [input.exec, ...input.scriptArgs].map(quoteIfNeeded).join(' ');
|
||||
// Un service systemd user démarre avec un PATH minimal (typiquement /usr/bin:/bin…) qui n'inclut
|
||||
// PAS ~/.local/bin ni le bin npm/nvm où vit le CLI `claude` → resolveClaudeBin() (`which claude`)
|
||||
// échouerait. On fige donc le PATH de l'environnement d'installation, où `claude` est résolvable.
|
||||
// Doubles quotes systemd : tolèrent un chemin du PATH contenant des espaces.
|
||||
const pathLine = input.pathEnv ? `\nEnvironment="PATH=${input.pathEnv}"` : '';
|
||||
// KillSignal=SIGTERM + TimeoutStopSec=10 collent au drain de runDaemon (SIGTERM → drain 1s → close).
|
||||
return `[Unit]
|
||||
Description=Arboretum — git worktree & Claude Code dashboard
|
||||
@@ -136,7 +141,7 @@ Restart=on-failure
|
||||
RestartSec=5
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=10
|
||||
Environment=NODE_ENV=production
|
||||
Environment=NODE_ENV=production${pathLine}
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -148,8 +153,14 @@ export function renderLaunchAgentPlist(input: {
|
||||
programArguments: string[];
|
||||
stdoutPath: string;
|
||||
stderrPath: string;
|
||||
pathEnv?: string | undefined;
|
||||
}): string {
|
||||
const args = input.programArguments.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
||||
// launchd démarre un LaunchAgent avec un PATH minimal qui n'inclut pas ~/.local/bin ni le bin
|
||||
// npm/nvm où vit le CLI `claude` → resolveClaudeBin() échouerait. On fige le PATH d'installation.
|
||||
const pathEntry = input.pathEnv
|
||||
? `\n <key>PATH</key>\n <string>${xmlEscape(input.pathEnv)}</string>`
|
||||
: '';
|
||||
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure (ne relance pas après un drain volontaire).
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -175,7 +186,7 @@ ${args}
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>NODE_ENV</key>
|
||||
<string>production</string>
|
||||
<string>production</string>${pathEntry}
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -258,9 +269,12 @@ export async function runInstall(argv: string[]): Promise<void> {
|
||||
const serviceArgs = buildServiceArgs(flags);
|
||||
const { exec, args: binArgs } = resolveBin(flags);
|
||||
const scriptArgs = [...binArgs, ...serviceArgs];
|
||||
// PATH de l'environnement d'installation (shell interactif où `claude` est résolvable) : on le
|
||||
// fige dans l'unit/plist car systemd/launchd démarrent le service avec un PATH minimal.
|
||||
const pathEnv = process.env.PATH;
|
||||
|
||||
if (platform === 'linux') {
|
||||
const unit = renderSystemdUnit({ exec, scriptArgs });
|
||||
const unit = renderSystemdUnit({ exec, scriptArgs, pathEnv });
|
||||
const unitPath = systemdUnitPath();
|
||||
if (flags.dryRun) {
|
||||
console.log(`# ${unitPath}\n${unit}\n# commands:`);
|
||||
@@ -295,6 +309,7 @@ export async function runInstall(argv: string[]): Promise<void> {
|
||||
programArguments,
|
||||
stdoutPath: logs.out,
|
||||
stderrPath: logs.err,
|
||||
pathEnv,
|
||||
});
|
||||
const plistPath = launchAgentPlistPath(flags.label);
|
||||
const uid = process.getuid?.() ?? 0;
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface Config {
|
||||
/** origins supplémentaires autorisées (ex. https://machine.tailnet.ts.net) */
|
||||
allowedOrigins: string[];
|
||||
printToken: boolean;
|
||||
/** racine effective de l'install Claude (~/.claude par défaut, --claude-home, ou réglage claude_home). */
|
||||
claudeHome: string;
|
||||
/** true si --claude-home a été passé explicitement → a priorité sur le réglage claude_home. */
|
||||
claudeHomeFromFlag: boolean;
|
||||
/** ~/.claude/projects (transcripts JSONL) — surchargeable via --claude-home (tests). */
|
||||
claudeProjectsDir: string;
|
||||
/** ~/.claude/sessions (registre des sessions CLI vivantes). */
|
||||
@@ -61,7 +65,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||
} catch {
|
||||
/* FS sans permissions POSIX : ignoré, cf. avertissement dans db.openDb */
|
||||
}
|
||||
const claudeHome = values['claude-home'] ?? join(homedir(), '.claude');
|
||||
const claudeHomeFlag = values['claude-home'];
|
||||
const claudeHome = claudeHomeFlag ?? join(homedir(), '.claude');
|
||||
return {
|
||||
port: Number(values.port),
|
||||
bind,
|
||||
@@ -69,6 +74,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||
dataDir,
|
||||
allowedOrigins: values['allow-origin'] ?? [],
|
||||
printToken: values['print-token'] ?? false,
|
||||
claudeHome,
|
||||
claudeHomeFromFlag: claudeHomeFlag !== undefined,
|
||||
claudeProjectsDir: join(claudeHome, 'projects'),
|
||||
claudeSessionsDir: join(claudeHome, 'sessions'),
|
||||
vapidContact: values['vapid-contact'] ?? 'mailto:arboretum@localhost',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
|
||||
export interface SpawnSpec {
|
||||
file: string;
|
||||
@@ -12,22 +13,74 @@ export interface SpawnOptions {
|
||||
resume?: { claudeSessionId: string; fork?: boolean };
|
||||
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
|
||||
addDirs?: string[];
|
||||
/** chemin explicite du binaire `claude` (réglage UI) ; sinon résolution via PATH (`which`). */
|
||||
claudeBinPath?: string | null;
|
||||
}
|
||||
|
||||
/** Diagnostic de résolution du binaire `claude` (exposé en lecture dans Réglages). */
|
||||
export interface ClaudeBinDiagnostic {
|
||||
/** chemin résolu du binaire, ou null si introuvable. */
|
||||
path: string | null;
|
||||
/** 'configured' = réglage explicite ; 'path' = trouvé via PATH ; null = introuvable. */
|
||||
source: 'configured' | 'path' | null;
|
||||
/** true si le binaire est présent et exécutable. */
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
let cachedClaudeBin: string | null = null;
|
||||
|
||||
export function resolveClaudeBin(): string {
|
||||
if (cachedClaudeBin) return cachedClaudeBin;
|
||||
/** Recherche `claude` dans le PATH (sans throw). null si absent. */
|
||||
function findClaudeOnPath(): string | null {
|
||||
try {
|
||||
cachedClaudeBin = execFileSync('which', ['claude'], { encoding: 'utf8' }).trim();
|
||||
return execFileSync('which', ['claude'], { encoding: 'utf8' }).trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isExecutable(path: string): boolean {
|
||||
try {
|
||||
accessSync(path, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le binaire `claude`. Si `configuredPath` est fourni (réglage UI), il est utilisé tel quel
|
||||
* — validé exécutable, message clair sinon — et JAMAIS mis en cache (modifiable à chaud). Sinon :
|
||||
* `which claude`, mis en cache. Un service systemd/launchd démarre avec un PATH minimal sans
|
||||
* ~/.local/bin → `which claude` y échoue ; d'où le réglage de chemin explicite (et le PATH figé par
|
||||
* `arboretum install`).
|
||||
*/
|
||||
export function resolveClaudeBin(configuredPath?: string | null): string {
|
||||
if (configuredPath) {
|
||||
if (!isExecutable(configuredPath)) {
|
||||
throw new Error(`Configured Claude CLI path is not executable: ${configuredPath}`);
|
||||
}
|
||||
return configuredPath;
|
||||
}
|
||||
if (cachedClaudeBin) return cachedClaudeBin;
|
||||
const found = findClaudeOnPath();
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
'Claude Code CLI not found in PATH. Install it first: https://code.claude.com/docs/en/quickstart',
|
||||
);
|
||||
}
|
||||
cachedClaudeBin = found;
|
||||
return cachedClaudeBin;
|
||||
}
|
||||
|
||||
/** Diagnostic non-throwing pour l'UI (Réglages) : recalculé à chaque appel, jamais caché. */
|
||||
export function diagnoseClaudeBin(configuredPath?: string | null): ClaudeBinDiagnostic {
|
||||
if (configuredPath) {
|
||||
return { path: configuredPath, source: 'configured', ok: isExecutable(configuredPath) };
|
||||
}
|
||||
const found = findClaudeOnPath();
|
||||
return found ? { path: found, source: 'path', ok: true } : { path: null, source: null, ok: false };
|
||||
}
|
||||
|
||||
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
||||
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
@@ -46,5 +99,5 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
||||
}
|
||||
// Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6).
|
||||
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
||||
return { file: resolveClaudeBin(), args, env };
|
||||
return { file: resolveClaudeBin(opts.claudeBinPath), args, env };
|
||||
}
|
||||
|
||||
63
packages/server/src/core/claude-settings.ts
Normal file
63
packages/server/src/core/claude-settings.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Réglages liés au CLI Claude, persistés dans la table `settings` (clé/valeur), modifiables via
|
||||
// l'onglet Réglages. Frontière de sécurité identique à scan-settings : clés NON sensibles, écrites
|
||||
// uniquement via l'allow-list du PATCH /api/v1/settings et les validateurs ci-dessous.
|
||||
//
|
||||
// Motivation : un service systemd/launchd démarre avec un PATH minimal qui n'inclut pas ~/.local/bin
|
||||
// (où vit le binaire `claude`) → `which claude` échoue. Plutôt que de bricoler le PATH du service,
|
||||
// l'utilisateur peut pointer explicitement le binaire ici (effet : prochaine session, sans redémarrage).
|
||||
import { accessSync, constants, statSync } from 'node:fs';
|
||||
import { getSetting } from '../db/index.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
import { isSafeAbsolutePath } from './git.js';
|
||||
|
||||
/** Chemin explicite du binaire `claude` (override de la résolution via PATH). */
|
||||
export const CLAUDE_BIN_PATH_KEY = 'claude_bin_path';
|
||||
/** Override de la racine ~/.claude (transcripts + registre des sessions). */
|
||||
export const CLAUDE_HOME_KEY = 'claude_home';
|
||||
|
||||
/** Chemin configuré du binaire `claude`, ou null si non défini (⇒ auto-détection via PATH). */
|
||||
export function readClaudeBinPath(db: Db): string | null {
|
||||
const raw = getSetting(db, CLAUDE_BIN_PATH_KEY)?.trim();
|
||||
return raw ? raw : null;
|
||||
}
|
||||
|
||||
/** Racine ~/.claude configurée, ou null si non défini (⇒ défaut/flag). */
|
||||
export function readClaudeHome(db: Db): string | null {
|
||||
const raw = getSetting(db, CLAUDE_HOME_KEY)?.trim();
|
||||
return raw ? raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide un chemin de binaire `claude`. Retourne `''` pour réinitialiser (auto-détection), le chemin
|
||||
* normalisé s'il est absolu ET pointe vers un fichier exécutable, ou `null` si invalide (⇒ 400).
|
||||
*/
|
||||
export function normalizeClaudeBinPath(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const p = raw.trim();
|
||||
if (p === '') return '';
|
||||
if (!isSafeAbsolutePath(p)) return null;
|
||||
try {
|
||||
if (!statSync(p).isFile()) return null;
|
||||
accessSync(p, constants.X_OK);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide une racine ~/.claude. Retourne `''` pour réinitialiser (défaut), le chemin normalisé s'il
|
||||
* est absolu ET pointe vers un répertoire existant, ou `null` si invalide (⇒ 400).
|
||||
*/
|
||||
export function normalizeClaudeHome(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const p = raw.trim();
|
||||
if (p === '') return '';
|
||||
if (!isSafeAbsolutePath(p)) return null;
|
||||
try {
|
||||
if (!statSync(p).isDirectory()) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
@@ -122,6 +122,11 @@ export function isSafeAbsolutePath(p: string): boolean {
|
||||
return p.startsWith('/') && resolve(p) === p && !p.split(sep).includes('..');
|
||||
}
|
||||
|
||||
/** Initialise un dépôt git dans `dir` (déjà créé et validé par l'appelant). `git init` est idempotent. */
|
||||
export async function gitInit(dir: string): Promise<void> {
|
||||
await git(dir, ['init']);
|
||||
}
|
||||
|
||||
/** true si `path` est la racine d'un dépôt git (main worktree) accessible. */
|
||||
export async function isRepo(path: string): Promise<boolean> {
|
||||
try {
|
||||
|
||||
33
packages/server/src/core/project.ts
Normal file
33
packages/server/src/core/project.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Création d'un nouveau projet : un dossier `<root>/<name>` créé sous une racine existante, dans
|
||||
// lequel on lance ensuite une session. Couche PURE (testable sans fs) — la création réelle du
|
||||
// dossier, le `git init` et le spawn vivent dans `routes/projects.ts`.
|
||||
import { join } from 'node:path';
|
||||
import { isSafeAbsolutePath } from './git.js';
|
||||
|
||||
/**
|
||||
* Valide un nom de projet : UN SEUL segment de dossier. Anti-traversal de base — refuse les noms
|
||||
* vides, trop longs, `.`/`..`, et tout caractère de séparation (`/`, `\`) ou NUL. La résolution du
|
||||
* chemin complet (et sa re-validation) est faite par {@link resolveProjectDir}.
|
||||
*/
|
||||
export function isSafeProjectName(name: string): boolean {
|
||||
const n = name.trim();
|
||||
if (n.length === 0 || n.length > 255) return false;
|
||||
if (n === '.' || n === '..') return false;
|
||||
return !/[/\\\0]/.test(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le dossier de projet `<root>/<name>`. Suppose `root` déjà validé absolu et existant par
|
||||
* l'appelant ; re-valide le chemin final via {@link isSafeAbsolutePath} (défense en profondeur).
|
||||
* Lève si `name` est invalide ou si le chemin résultant échappe l'arborescence.
|
||||
*/
|
||||
export function resolveProjectDir(root: string, name: string): string {
|
||||
if (!isSafeProjectName(name)) {
|
||||
throw Object.assign(new Error(`Invalid project name: ${name}`), { statusCode: 400 });
|
||||
}
|
||||
const dir = join(root, name.trim());
|
||||
if (!isSafeAbsolutePath(dir)) {
|
||||
throw Object.assign(new Error(`Unsafe project path: ${dir}`), { statusCode: 400 });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
@@ -7,12 +7,15 @@ import pty from '@homebridge/node-pty-prebuilt-multiarch';
|
||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionActivity, type SessionSummary } from '@arboretum/shared';
|
||||
import { RingBuffer } from './ring-buffer.js';
|
||||
import { buildSpawnSpec } from './claude-launcher.js';
|
||||
import { readClaudeBinPath } from './claude-settings.js';
|
||||
import { findByPid } from './session-registry.js';
|
||||
import { SessionActivityTracker } from './claude-adapter.js';
|
||||
import type { PushService } from './push-service.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
|
||||
const RING_CAPACITY = 2 * 1024 * 1024;
|
||||
// 4 Mo : conserve assez d'historique pour que le replay (REPLAY_TAIL_BYTES = 1 Mo) reste largement
|
||||
// dans le ring et qu'on puisse remonter une conversation Claude après ré-attache.
|
||||
const RING_CAPACITY = 4 * 1024 * 1024;
|
||||
const KILL_GRACE_MS = 5000;
|
||||
/** Délai avant envoi d'une notif push sur passage en `waiting` : annulé si la session repart (faux positif). */
|
||||
const NOTIFY_DEBOUNCE_MS = 1500;
|
||||
@@ -109,8 +112,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
}
|
||||
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
|
||||
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
|
||||
// Override de chemin du binaire claude (réglage UI) lu à chaque spawn → effet sans redémarrage.
|
||||
const claudeBinPath = command === 'claude' ? readClaudeBinPath(this.db) : null;
|
||||
const spec = buildSpawnSpec({
|
||||
command,
|
||||
...(claudeBinPath ? { claudeBinPath } : {}),
|
||||
...(opts.resume ? { resume: opts.resume } : {}),
|
||||
...(addedDirs.length ? { addDirs: addedDirs } : {}),
|
||||
});
|
||||
@@ -401,30 +407,35 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
// ---- interne ----
|
||||
|
||||
/**
|
||||
* Push P4-B : notifie sur le FRONT MONTANT vers `waiting` uniquement (le tracker réémet
|
||||
* souvent le même état), avec un debounce annulable — un `waiting` ultra-bref (Claude répond
|
||||
* tout seul) ne déclenche pas de notif. Cible tous les abonnements (un seul utilisateur).
|
||||
* Push P4-B : notifie sur le FRONT MONTANT vers un état qui requiert l'attention — `waiting`
|
||||
* (un dialogue bloque) ou `idle` atteint depuis `busy` (Claude a terminé sa réponse, la session
|
||||
* redevient disponible). Le tracker réémet souvent le même état → on ne réagit qu'au changement.
|
||||
* Debounce annulable : un état ultra-bref (Claude repart tout seul) ne déclenche pas de notif —
|
||||
* à l'échéance on revérifie l'état réel. Cible tous les abonnements (un seul utilisateur).
|
||||
*/
|
||||
private maybeNotify(s: ManagedSession, next: SessionActivity | null): void {
|
||||
const prev = s.prevActivity;
|
||||
s.prevActivity = next;
|
||||
if (!this.push) return;
|
||||
if (next === 'waiting' && prev !== 'waiting') {
|
||||
const notifiable =
|
||||
(next === 'waiting' && prev !== 'waiting') || (next === 'idle' && prev === 'busy');
|
||||
if (notifiable) {
|
||||
const target = next; // 'waiting' | 'idle'
|
||||
if (s.notifyTimer) clearTimeout(s.notifyTimer);
|
||||
s.notifyTimer = setTimeout(() => {
|
||||
s.notifyTimer = null;
|
||||
const act = s.tracker?.snapshot();
|
||||
if (s.exited || act?.activity !== 'waiting') return; // faux positif : annulé
|
||||
void this.push?.notify({
|
||||
sessionId: s.id,
|
||||
title: basename(s.cwd) || s.cwd,
|
||||
body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input',
|
||||
kind: act.dialog?.kind ?? null,
|
||||
url: `/sessions/${s.id}`,
|
||||
});
|
||||
if (s.exited || act?.activity !== target) return; // faux positif : l'état a déjà changé
|
||||
const base = { sessionId: s.id, title: basename(s.cwd) || s.cwd, url: `/sessions/${s.id}` };
|
||||
void this.push?.notify(
|
||||
target === 'idle'
|
||||
? { ...base, body: 'available for new instructions', kind: null }
|
||||
: { ...base, body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input', kind: act.dialog?.kind ?? null },
|
||||
);
|
||||
}, NOTIFY_DEBOUNCE_MS);
|
||||
s.notifyTimer.unref();
|
||||
} else if (next !== 'waiting' && s.notifyTimer) {
|
||||
} else if (s.notifyTimer && next !== 'waiting' && next !== 'idle') {
|
||||
// On quitte un état notifiable avant l'échéance (ex. Claude repart en busy) → annule.
|
||||
clearTimeout(s.notifyTimer);
|
||||
s.notifyTimer = null;
|
||||
}
|
||||
|
||||
@@ -250,10 +250,18 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
|
||||
// ---- worktrees ----
|
||||
|
||||
/** Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree. */
|
||||
private sessionsForCwd(path: string): SessionSummary[] {
|
||||
/**
|
||||
* Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree.
|
||||
* Les sessions explicitement masquées (`hidden`) sont exclues — cohérent avec `/api/v1/sessions`
|
||||
* (sans quoi le masquage était ignoré dans les fiches worktree). Le tri managées/externes est laissé
|
||||
* au client (interrupteur « afficher les externes »), qui dispose du champ `source`. La garde de
|
||||
* suppression réclame en revanche TOUTES les sessions vivantes (`includeHidden`) pour rester sûre.
|
||||
*/
|
||||
private sessionsForCwd(path: string, opts?: { includeHidden?: boolean }): SessionSummary[] {
|
||||
const rp = resolve(path);
|
||||
return mergeSessions(this.ptyManager.list(), this.discovery.list()).filter((s) => resolve(s.cwd) === rp);
|
||||
return mergeSessions(this.ptyManager.list(), this.discovery.list())
|
||||
.filter((s) => resolve(s.cwd) === rp)
|
||||
.filter((s) => opts?.includeHidden || !s.hidden);
|
||||
}
|
||||
|
||||
private toSummary(repoId: string, repoPath: string, w: ParsedWorktree, status: WorktreeGitStatus): WorktreeSummary {
|
||||
@@ -507,7 +515,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||
if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'Cannot remove the main worktree');
|
||||
// garde-fou : une session vivante tourne dans ce worktree → exiger une confirmation explicite.
|
||||
if (!force && this.sessionsForCwd(w.path).some((s) => s.live)) {
|
||||
if (!force && this.sessionsForCwd(w.path, { includeHidden: true }).some((s) => s.live)) {
|
||||
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree — pass force to delete anyway');
|
||||
}
|
||||
return this.withLock(repoId, async () => {
|
||||
|
||||
@@ -3,17 +3,33 @@ import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadConfig, type Config } from './config.js';
|
||||
import { openDb } from './db/index.js';
|
||||
import { openDb, type Db } from './db/index.js';
|
||||
import { buildApp } from './app.js';
|
||||
import { readClaudeHome } from './core/claude-settings.js';
|
||||
import { runInstall, runUninstall, runStatus, printUsage, printTokenBanner } from './cli/install.js';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
||||
) as { version: string };
|
||||
|
||||
/**
|
||||
* Applique l'override `claude_home` (réglage UI) sur la config runtime, sauf si --claude-home a été
|
||||
* passé explicitement (le flag gagne). Lu une seule fois au démarrage → un changement via l'UI prend
|
||||
* effet au redémarrage du daemon (comme l'intervalle de scan). Mute `config`.
|
||||
*/
|
||||
function applyClaudeHomeOverride(config: Config, db: Db): void {
|
||||
if (config.claudeHomeFromFlag) return;
|
||||
const override = readClaudeHome(db);
|
||||
if (!override) return;
|
||||
config.claudeHome = override;
|
||||
config.claudeProjectsDir = join(override, 'projects');
|
||||
config.claudeSessionsDir = join(override, 'sessions');
|
||||
}
|
||||
|
||||
/** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */
|
||||
export async function runDaemon(config: Config): Promise<void> {
|
||||
const db = openDb(config.dbPath);
|
||||
applyClaudeHomeOverride(config, db); // override claude_home (réglage UI) avant de câbler les services
|
||||
const { app, auth, manager, discovery, repoDiscovery } = buildApp(config, db, pkg.version);
|
||||
|
||||
const bootstrapToken = auth.ensureBootstrapToken();
|
||||
|
||||
89
packages/server/src/routes/projects.ts
Normal file
89
packages/server/src/routes/projects.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { mkdir, stat } from 'node:fs/promises';
|
||||
import type { CreateProjectRequest, CreateProjectResponse } from '@arboretum/shared';
|
||||
import type { PtyManager } from '../core/pty-manager.js';
|
||||
import { gitInit, isSafeAbsolutePath } from '../core/git.js';
|
||||
import { resolveProjectDir } from '../core/project.js';
|
||||
import { recordAudit } from '../core/audit-log.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
|
||||
/**
|
||||
* Création d'un nouveau projet : crée le dossier `<root>/<name>` (la SEULE écriture fs autorisée
|
||||
* côté serveur en dehors du dataDir) puis lance une session dedans. `git init` optionnel.
|
||||
* Cohérent avec le modèle de sécurité : un client authentifié dispose déjà d'un terminal (RCE par
|
||||
* conception). On valide tout de même strictement `name` (anti-traversal) et on exige une racine
|
||||
* EXISTANTE (mkdir non récursif → pas de création d'arborescence arbitraire).
|
||||
*
|
||||
* POST /api/v1/projects { root, name, gitInit?, command? } → 201 { session, path, gitInitialized }
|
||||
*/
|
||||
export function registerProjectRoutes(app: FastifyInstance, manager: PtyManager, db: Db): void {
|
||||
app.post('/api/v1/projects', async (req, reply) => {
|
||||
const body = req.body as Partial<CreateProjectRequest> | null;
|
||||
if (!body || typeof body.root !== 'string' || !isSafeAbsolutePath(body.root)) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'root (absolute path) is required' } });
|
||||
}
|
||||
if (typeof body.name !== 'string') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'name is required' } });
|
||||
}
|
||||
if (body.command !== undefined && body.command !== 'claude' && body.command !== 'bash') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
|
||||
}
|
||||
|
||||
// La racine doit exister et être un répertoire (mkdir non récursif derrière).
|
||||
try {
|
||||
const st = await stat(body.root);
|
||||
if (!st.isDirectory()) {
|
||||
return reply.status(400).send({ error: { code: 'NOT_A_DIRECTORY', message: `Not a directory: ${body.root}` } });
|
||||
}
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') return reply.status(404).send({ error: { code: 'NOT_FOUND', message: `No such directory: ${body.root}` } });
|
||||
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${body.root}` } });
|
||||
return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } });
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
try {
|
||||
dir = resolveProjectDir(body.root, body.name);
|
||||
} catch (err) {
|
||||
return reply.status((err as { statusCode?: number }).statusCode ?? 400).send({ error: { code: 'BAD_REQUEST', message: (err as Error).message } });
|
||||
}
|
||||
|
||||
// mkdir non récursif : EEXIST → 409 (on n'écrase jamais un dossier existant).
|
||||
try {
|
||||
await mkdir(dir);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'EEXIST') return reply.status(409).send({ error: { code: 'PROJECT_EXISTS', message: `Already exists: ${dir}` } });
|
||||
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${dir}` } });
|
||||
return reply.status(400).send({ error: { code: 'MKDIR_FAILED', message: (err as Error).message } });
|
||||
}
|
||||
|
||||
const wantsGitInit = body.gitInit === true;
|
||||
let gitInitialized = false;
|
||||
if (wantsGitInit) {
|
||||
try {
|
||||
await gitInit(dir);
|
||||
gitInitialized = true;
|
||||
} catch {
|
||||
// Le dossier est créé et la session démarrera quand même : un échec git n'annule pas le projet.
|
||||
gitInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const session = manager.spawn({ cwd: dir, ...(body.command ? { command: body.command } : {}) });
|
||||
recordAudit(db, {
|
||||
actor: req.authContext?.tokenId ?? 'unknown',
|
||||
action: 'project.create',
|
||||
resourceId: dir,
|
||||
details: { gitInit: gitInitialized },
|
||||
});
|
||||
const res: CreateProjectResponse = { session, path: dir, gitInitialized };
|
||||
return reply.status(201).send(res);
|
||||
} catch (err) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
|
||||
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { Config } from '../config.js';
|
||||
import { type Db, setSetting } from '../db/index.js';
|
||||
import type { PushService } from '../core/push-service.js';
|
||||
import { recordAudit } from '../core/audit-log.js';
|
||||
import { diagnoseClaudeBin } from '../core/claude-launcher.js';
|
||||
import {
|
||||
SCAN_INTERVAL_KEY,
|
||||
SCAN_ROOTS_KEY,
|
||||
@@ -15,6 +16,14 @@ import {
|
||||
readScanIntervalMin,
|
||||
readScanRoots,
|
||||
} from '../core/scan-settings.js';
|
||||
import {
|
||||
CLAUDE_BIN_PATH_KEY,
|
||||
CLAUDE_HOME_KEY,
|
||||
normalizeClaudeBinPath,
|
||||
normalizeClaudeHome,
|
||||
readClaudeBinPath,
|
||||
readClaudeHome,
|
||||
} from '../core/claude-settings.js';
|
||||
|
||||
// Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
|
||||
export function registerSettingsRoutes(
|
||||
@@ -32,11 +41,16 @@ export function registerSettingsRoutes(
|
||||
dataDir: config.dataDir,
|
||||
vapidPublicKey: push.publicKey() || null,
|
||||
vapidContact: config.vapidContact,
|
||||
claudeHome: config.claudeHome,
|
||||
// Diagnostic recalculé à chaque GET : reflète l'état réel (override exécutable ? `which claude` ?).
|
||||
claudeBin: diagnoseClaudeBin(readClaudeBinPath(db)),
|
||||
});
|
||||
const snapshot = (): SettingsResponse => ({
|
||||
settings: {
|
||||
scanRoots: readScanRoots(db),
|
||||
scanIntervalMin: readScanIntervalMin(db),
|
||||
claudeBinPath: readClaudeBinPath(db),
|
||||
claudeHome: readClaudeHome(db),
|
||||
},
|
||||
server: serverInfo(),
|
||||
});
|
||||
@@ -59,6 +73,20 @@ export function registerSettingsRoutes(
|
||||
}
|
||||
setSetting(db, SCAN_INTERVAL_KEY, String(interval));
|
||||
}
|
||||
if ('claudeBinPath' in body) {
|
||||
const value = normalizeClaudeBinPath(body.claudeBinPath);
|
||||
if (value === null) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'claudeBinPath must be an absolute path to an executable file (or "" to reset)' } });
|
||||
}
|
||||
setSetting(db, CLAUDE_BIN_PATH_KEY, value);
|
||||
}
|
||||
if ('claudeHome' in body) {
|
||||
const value = normalizeClaudeHome(body.claudeHome);
|
||||
if (value === null) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'claudeHome must be an absolute path to an existing directory (or "" to reset)' } });
|
||||
}
|
||||
setSetting(db, CLAUDE_HOME_KEY, value);
|
||||
}
|
||||
recordAudit(db, {
|
||||
actor: req.authContext?.tokenId ?? 'unknown',
|
||||
action: 'settings.update',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { buildSpawnSpec } from '../src/core/claude-launcher.js';
|
||||
import { buildSpawnSpec, diagnoseClaudeBin, resolveClaudeBin } from '../src/core/claude-launcher.js';
|
||||
|
||||
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||
@@ -27,3 +27,28 @@ describe('buildSpawnSpec — session de groupe multi-repo (P6)', () => {
|
||||
expect(spec.args).toEqual(['--norc']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClaudeBin / diagnoseClaudeBin — override de chemin (réglage UI)', () => {
|
||||
// process.execPath (node) est un fichier exécutable réel garanti sur la machine de test.
|
||||
const realBin = process.execPath;
|
||||
|
||||
it('buildSpawnSpec utilise le chemin configuré tel quel quand fourni', () => {
|
||||
const spec = buildSpawnSpec({ command: 'claude', claudeBinPath: realBin });
|
||||
expect(spec.file).toBe(realBin);
|
||||
});
|
||||
|
||||
it('resolveClaudeBin retombe sur le PATH (which) sans override', () => {
|
||||
expect(resolveClaudeBin()).toBe('/usr/bin/claude');
|
||||
expect(resolveClaudeBin(null)).toBe('/usr/bin/claude');
|
||||
});
|
||||
|
||||
it('resolveClaudeBin throw si le chemin configuré n’est pas exécutable', () => {
|
||||
expect(() => resolveClaudeBin('/no/such/claude')).toThrow(/not executable/);
|
||||
});
|
||||
|
||||
it('diagnoseClaudeBin : configuré+exécutable → ok ; configuré absent → !ok ; sinon PATH', () => {
|
||||
expect(diagnoseClaudeBin(realBin)).toEqual({ path: realBin, source: 'configured', ok: true });
|
||||
expect(diagnoseClaudeBin('/no/such/claude')).toEqual({ path: '/no/such/claude', source: 'configured', ok: false });
|
||||
expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
89
packages/server/test/claude-settings.test.ts
Normal file
89
packages/server/test/claude-settings.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Réglages CLI Claude (chemin du binaire + override ~/.claude) : validateurs + lecture/écriture DB.
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { openDb, setSetting, type Db } from '../src/db/index.js';
|
||||
import {
|
||||
CLAUDE_BIN_PATH_KEY,
|
||||
CLAUDE_HOME_KEY,
|
||||
normalizeClaudeBinPath,
|
||||
normalizeClaudeHome,
|
||||
readClaudeBinPath,
|
||||
readClaudeHome,
|
||||
} from '../src/core/claude-settings.js';
|
||||
|
||||
let dir: string;
|
||||
let execFile: string;
|
||||
let plainFile: string;
|
||||
let subDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'arboretum-claude-settings-'));
|
||||
execFile = join(dir, 'claude');
|
||||
writeFileSync(execFile, '#!/bin/sh\n');
|
||||
chmodSync(execFile, 0o755); // exécutable
|
||||
plainFile = join(dir, 'notexec');
|
||||
writeFileSync(plainFile, 'x');
|
||||
chmodSync(plainFile, 0o644); // non exécutable
|
||||
subDir = join(dir, 'home');
|
||||
mkdirSync(subDir);
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
describe('normalizeClaudeBinPath', () => {
|
||||
it('accepte un chemin absolu vers un fichier exécutable', () => {
|
||||
expect(normalizeClaudeBinPath(execFile)).toBe(execFile);
|
||||
});
|
||||
it('"" = réinitialisation (auto-détection)', () => {
|
||||
expect(normalizeClaudeBinPath('')).toBe('');
|
||||
expect(normalizeClaudeBinPath(' ')).toBe('');
|
||||
});
|
||||
it('rejette un fichier non exécutable, un répertoire, un chemin relatif ou avec ".." (null)', () => {
|
||||
expect(normalizeClaudeBinPath(plainFile)).toBeNull();
|
||||
expect(normalizeClaudeBinPath(subDir)).toBeNull(); // répertoire, pas un fichier
|
||||
expect(normalizeClaudeBinPath('relative/claude')).toBeNull();
|
||||
expect(normalizeClaudeBinPath(join(dir, '..', 'x'))).toBeNull();
|
||||
expect(normalizeClaudeBinPath(join(dir, 'missing'))).toBeNull();
|
||||
expect(normalizeClaudeBinPath(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeClaudeHome', () => {
|
||||
it('accepte un chemin absolu vers un répertoire existant', () => {
|
||||
expect(normalizeClaudeHome(subDir)).toBe(subDir);
|
||||
});
|
||||
it('"" = réinitialisation (défaut)', () => {
|
||||
expect(normalizeClaudeHome('')).toBe('');
|
||||
});
|
||||
it('rejette un fichier, un chemin relatif/inexistant (null)', () => {
|
||||
expect(normalizeClaudeHome(execFile)).toBeNull(); // fichier, pas un répertoire
|
||||
expect(normalizeClaudeHome('relative/.claude')).toBeNull();
|
||||
expect(normalizeClaudeHome(join(dir, 'missing'))).toBeNull();
|
||||
expect(normalizeClaudeHome(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readClaudeBinPath / readClaudeHome', () => {
|
||||
let db: Db;
|
||||
beforeAll(() => {
|
||||
db = openDb(join(dir, 'settings.db'));
|
||||
});
|
||||
afterAll(() => db.close());
|
||||
|
||||
it('null quand non défini', () => {
|
||||
expect(readClaudeBinPath(db)).toBeNull();
|
||||
expect(readClaudeHome(db)).toBeNull();
|
||||
});
|
||||
it('lit la valeur stockée et traite "" stockée comme null', () => {
|
||||
setSetting(db, CLAUDE_BIN_PATH_KEY, execFile);
|
||||
setSetting(db, CLAUDE_HOME_KEY, subDir);
|
||||
expect(readClaudeBinPath(db)).toBe(execFile);
|
||||
expect(readClaudeHome(db)).toBe(subDir);
|
||||
setSetting(db, CLAUDE_BIN_PATH_KEY, ''); // réinitialisé
|
||||
setSetting(db, CLAUDE_HOME_KEY, '');
|
||||
expect(readClaudeBinPath(db)).toBeNull();
|
||||
expect(readClaudeHome(db)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,21 @@ describe('cli install — renderSystemdUnit', () => {
|
||||
expect(unit).toContain('ExecStart="/path with space/node" /s.js');
|
||||
});
|
||||
|
||||
it('fige le PATH d\'installation quand fourni (le service systemd a un PATH minimal)', () => {
|
||||
const unit = renderSystemdUnit({
|
||||
exec: '/usr/bin/node',
|
||||
scriptArgs: ['/s.js'],
|
||||
pathEnv: '/home/me/.local/bin:/usr/bin',
|
||||
});
|
||||
// Doubles quotes systemd : un chemin du PATH peut contenir un espace.
|
||||
expect(unit).toContain('Environment="PATH=/home/me/.local/bin:/usr/bin"');
|
||||
});
|
||||
|
||||
it("n'émet aucune ligne PATH sans pathEnv (rétrocompat)", () => {
|
||||
const unit = renderSystemdUnit({ exec: '/usr/bin/node', scriptArgs: ['/s.js'] });
|
||||
expect(unit).not.toContain('PATH=');
|
||||
});
|
||||
|
||||
it('snapshot du unit pour un jeu de flags fixe', () => {
|
||||
const unit = renderSystemdUnit({
|
||||
exec: '/usr/bin/node',
|
||||
@@ -133,6 +148,19 @@ describe('cli install — renderLaunchAgentPlist', () => {
|
||||
expect(plist).toContain('https://a?b&c=d');
|
||||
expect(plist).not.toContain('b&c=d');
|
||||
});
|
||||
|
||||
it('ajoute la clé PATH dans EnvironmentVariables quand fournie (launchd a un PATH minimal)', () => {
|
||||
const plist = renderLaunchAgentPlist({ ...base, pathEnv: '/Users/me/.local/bin:/usr/bin' });
|
||||
expect(plist).toContain('<key>PATH</key>');
|
||||
expect(plist).toContain('<string>/Users/me/.local/bin:/usr/bin</string>');
|
||||
// Reste dans le dict EnvironmentVariables, juste après NODE_ENV.
|
||||
expect(plist.indexOf('<key>PATH</key>')).toBeGreaterThan(plist.indexOf('<key>NODE_ENV</key>'));
|
||||
});
|
||||
|
||||
it("n'ajoute aucune clé PATH sans pathEnv (rétrocompat)", () => {
|
||||
const plist = renderLaunchAgentPlist(base);
|
||||
expect(plist).not.toContain('<key>PATH</key>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cli install — xmlEscape', () => {
|
||||
|
||||
45
packages/server/test/project.test.ts
Normal file
45
packages/server/test/project.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isSafeProjectName, resolveProjectDir } from '../src/core/project.js';
|
||||
|
||||
describe('isSafeProjectName', () => {
|
||||
it('accepte un segment simple', () => {
|
||||
expect(isSafeProjectName('mon-projet')).toBe(true);
|
||||
expect(isSafeProjectName('Projet_42.v2')).toBe(true);
|
||||
expect(isSafeProjectName(' espaces-trim ')).toBe(true); // trim interne
|
||||
});
|
||||
|
||||
it('refuse vide / espaces seuls', () => {
|
||||
expect(isSafeProjectName('')).toBe(false);
|
||||
expect(isSafeProjectName(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuse `.` et `..`', () => {
|
||||
expect(isSafeProjectName('.')).toBe(false);
|
||||
expect(isSafeProjectName('..')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuse les séparateurs et le NUL (anti-traversal)', () => {
|
||||
expect(isSafeProjectName('a/b')).toBe(false);
|
||||
expect(isSafeProjectName('../evil')).toBe(false);
|
||||
expect(isSafeProjectName('a\\b')).toBe(false);
|
||||
expect(isSafeProjectName('a\0b')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuse un nom trop long (> 255)', () => {
|
||||
expect(isSafeProjectName('x'.repeat(256))).toBe(false);
|
||||
expect(isSafeProjectName('x'.repeat(255))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProjectDir', () => {
|
||||
it('joint root + name (trim) en chemin absolu', () => {
|
||||
expect(resolveProjectDir('/home/johan/dev', 'mon-projet')).toBe('/home/johan/dev/mon-projet');
|
||||
expect(resolveProjectDir('/home/johan/dev', ' mon-projet ')).toBe('/home/johan/dev/mon-projet');
|
||||
});
|
||||
|
||||
it('lève (400) sur un nom invalide', () => {
|
||||
expect(() => resolveProjectDir('/home/johan/dev', '..')).toThrow();
|
||||
expect(() => resolveProjectDir('/home/johan/dev', 'a/b')).toThrow();
|
||||
expect(() => resolveProjectDir('/home/johan/dev', '')).toThrow();
|
||||
});
|
||||
});
|
||||
116
packages/server/test/projects-routes.test.ts
Normal file
116
packages/server/test/projects-routes.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { mkdtempSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { buildApp, type AppBundle } from '../src/app.js';
|
||||
import { openDb, type Db } from '../src/db/index.js';
|
||||
import type { Config } from '../src/config.js';
|
||||
import type { CreateProjectResponse } from '@arboretum/shared';
|
||||
|
||||
// On NE mocke PAS node:child_process : on veut un vrai `git init` (testé via command:'bash', qui
|
||||
// n'a pas besoin de résoudre le binaire claude). Seul le PTY est simulé (pas de vrai process en CI).
|
||||
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||
class FakePty {
|
||||
pid = 424242;
|
||||
write = vi.fn();
|
||||
resize = vi.fn();
|
||||
pause = vi.fn();
|
||||
resume = vi.fn();
|
||||
kill = vi.fn();
|
||||
onData(): { dispose: () => void } {
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
onExit(): { dispose: () => void } {
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
}
|
||||
return { default: { spawn: (): FakePty => new FakePty() } };
|
||||
});
|
||||
|
||||
process.env.ARBORETUM_LOG = 'silent';
|
||||
|
||||
let dir: string;
|
||||
let root: string;
|
||||
let bundle: AppBundle;
|
||||
let db: Db;
|
||||
let token: string;
|
||||
|
||||
const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` });
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'arboretum-projects-'));
|
||||
root = dir; // la racine existe déjà
|
||||
|
||||
const dbPath = join(dir, 'projects.db');
|
||||
db = openDb(dbPath);
|
||||
const config: Config = {
|
||||
port: 7317,
|
||||
bind: '127.0.0.1',
|
||||
dbPath,
|
||||
dataDir: dir,
|
||||
allowedOrigins: [],
|
||||
printToken: false,
|
||||
claudeProjectsDir: join(dir, 'claude', 'projects'),
|
||||
claudeSessionsDir: join(dir, 'claude', 'sessions'),
|
||||
vapidContact: 'mailto:test@localhost',
|
||||
};
|
||||
bundle = buildApp(config, db, '0.0.0-test');
|
||||
const t = bundle.auth.ensureBootstrapToken();
|
||||
if (!t) throw new Error('bootstrap token attendu sur une base vierge');
|
||||
token = t;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await bundle.app.close();
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('POST /api/v1/projects', () => {
|
||||
it('crée le dossier et lance une session (sans git)', async () => {
|
||||
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'plain', command: 'bash' } });
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json() as CreateProjectResponse;
|
||||
expect(body.path).toBe(join(root, 'plain'));
|
||||
expect(body.gitInitialized).toBe(false);
|
||||
expect(existsSync(join(root, 'plain'))).toBe(true);
|
||||
expect(existsSync(join(root, 'plain', '.git'))).toBe(false);
|
||||
expect(body.session.cwd).toBe(join(root, 'plain'));
|
||||
});
|
||||
|
||||
it('gitInit:true crée un dépôt git', async () => {
|
||||
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'withgit', gitInit: true, command: 'bash' } });
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json() as CreateProjectResponse;
|
||||
expect(body.gitInitialized).toBe(true);
|
||||
expect(existsSync(join(root, 'withgit', '.git'))).toBe(true);
|
||||
});
|
||||
|
||||
it('nom avec `..` ou séparateur → 400 (anti-traversal)', async () => {
|
||||
for (const name of ['..', 'a/b', '../evil']) {
|
||||
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name, command: 'bash' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('dossier déjà existant → 409 PROJECT_EXISTS', async () => {
|
||||
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'plain', command: 'bash' } });
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toMatchObject({ error: { code: 'PROJECT_EXISTS' } });
|
||||
});
|
||||
|
||||
it('racine inexistante → 404', async () => {
|
||||
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root: join(root, 'nope-xyz'), name: 'x', command: 'bash' } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('root relatif → 400', async () => {
|
||||
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root: 'relatif/x', name: 'x', command: 'bash' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('sans authentification → 401', async () => {
|
||||
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', payload: { root, name: 'noauth', command: 'bash' } });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -243,7 +243,11 @@ describe('PtyManager (pty mocké)', () => {
|
||||
|
||||
it('resync à l’attach = queue du ring (REPLAY_TAIL_BYTES max)', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const chunks = ['A', 'B', 'C'].map((c) => c.repeat(100 * 1024));
|
||||
// on écrit volontairement PLUS que REPLAY_TAIL_BYTES (mais < RING_CAPACITY) pour vérifier
|
||||
// le plafonnement de la queue rejouée. Tailles dérivées de la constante → robuste aux bumps.
|
||||
const chunkSize = 256 * 1024;
|
||||
const chunkCount = Math.ceil(REPLAY_TAIL_BYTES / chunkSize) + 2;
|
||||
const chunks = Array.from({ length: chunkCount }, (_, i) => String.fromCharCode(65 + (i % 26)).repeat(chunkSize));
|
||||
for (const c of chunks) pty.emitData(c);
|
||||
|
||||
const b = makeBinding('interactive');
|
||||
@@ -410,6 +414,44 @@ describe('PtyManager (pty mocké)', () => {
|
||||
rmSync(sessDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('une notif sur le front montant busy→idle (Claude a terminé → disponible), après le debounce', async () => {
|
||||
const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-idle-'));
|
||||
const notifies: PushPayload[] = [];
|
||||
const fakePush = {
|
||||
notify: async (p: PushPayload) => {
|
||||
notifies.push(p);
|
||||
},
|
||||
} as unknown as PushService;
|
||||
const m = new PtyManager(db, sessDir, fakePush);
|
||||
try {
|
||||
const summary = m.spawn({ cwd, command: 'claude' });
|
||||
const p = lastPty();
|
||||
const regFile = join(sessDir, `${p.pid}.json`);
|
||||
const writeReg = (status: string): void =>
|
||||
writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status }));
|
||||
|
||||
// busy d'abord : front montant vers busy, pas de notif
|
||||
writeReg('busy');
|
||||
p.emitData('working…');
|
||||
await sleep(260);
|
||||
expect(notifies).toHaveLength(0);
|
||||
|
||||
// idle : Claude a terminé → front busy→idle → planifie la notif (debounce 1500ms)
|
||||
writeReg('idle');
|
||||
p.emitData('\x1b[2J\x1b[HDone.\r\n');
|
||||
await sleep(260);
|
||||
expect(notifies).toHaveLength(0); // pas encore : debounce en cours
|
||||
|
||||
await sleep(1500); // dépasse le debounce
|
||||
expect(notifies).toHaveLength(1);
|
||||
expect(notifies[0]).toMatchObject({ sessionId: summary.id, kind: null, url: `/sessions/${summary.id}` });
|
||||
expect(notifies[0].body).toMatch(/available/i);
|
||||
} finally {
|
||||
m.shutdown();
|
||||
rmSync(sessDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('flow control', () => {
|
||||
|
||||
@@ -48,6 +48,8 @@ beforeAll(() => {
|
||||
dataDir: dir,
|
||||
allowedOrigins: ['https://host.tailnet.ts.net'],
|
||||
printToken: false,
|
||||
claudeHome: join(dir, 'claude'),
|
||||
claudeHomeFromFlag: false,
|
||||
claudeProjectsDir: join(dir, 'claude', 'projects'),
|
||||
claudeSessionsDir: join(dir, 'claude', 'sessions'),
|
||||
vapidContact: 'mailto:test@localhost',
|
||||
@@ -77,6 +79,11 @@ describe('GET /api/v1/settings', () => {
|
||||
// défauts de découverte : AUCUNE racine (clean install → pas de scan) + intervalle 5 min
|
||||
expect(body.settings.scanRoots).toEqual([]);
|
||||
expect(body.settings.scanIntervalMin).toBe(5);
|
||||
// Claude CLI : aucun override par défaut + diagnostic via PATH (execFileSync mocké → /usr/bin/claude).
|
||||
expect(body.settings.claudeBinPath).toBeNull();
|
||||
expect(body.settings.claudeHome).toBeNull();
|
||||
expect(body.server.claudeHome).toBe(join(dir, 'claude'));
|
||||
expect(body.server.claudeBin).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
|
||||
});
|
||||
|
||||
it('n’expose AUCUN secret (server_secret, clé privée VAPID)', async () => {
|
||||
@@ -137,3 +144,46 @@ describe('PATCH /api/v1/settings — découverte des dépôts', () => {
|
||||
expect(neg.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/settings — Claude CLI', () => {
|
||||
// process.execPath (node) est un fichier exécutable réel garanti sur la machine de test.
|
||||
const realBin = process.execPath;
|
||||
|
||||
it('enregistre un chemin de binaire exécutable et reflète le diagnostic (source=configured)', async () => {
|
||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as SettingsResponse;
|
||||
expect(body.settings.claudeBinPath).toBe(realBin);
|
||||
expect(body.server.claudeBin).toEqual({ path: realBin, source: 'configured', ok: true });
|
||||
});
|
||||
|
||||
it('réinitialise le chemin avec "" (retour à l’auto-détection via PATH)', async () => {
|
||||
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } });
|
||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: '' } });
|
||||
const body = res.json() as SettingsResponse;
|
||||
expect(body.settings.claudeBinPath).toBeNull();
|
||||
expect(body.server.claudeBin.source).toBe('path'); // de nouveau via PATH (mock → /usr/bin/claude)
|
||||
});
|
||||
|
||||
it('rejette un chemin non absolu ou non exécutable (400)', async () => {
|
||||
for (const claudeBinPath of ['relative/claude', '/a/../b', dir /* répertoire, pas un fichier */]) {
|
||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('enregistre un override claude_home (répertoire existant) et le réinitialise avec ""', async () => {
|
||||
const set = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: dir } });
|
||||
expect(set.statusCode).toBe(200);
|
||||
expect((set.json() as SettingsResponse).settings.claudeHome).toBe(dir);
|
||||
const reset = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: '' } });
|
||||
expect((reset.json() as SettingsResponse).settings.claudeHome).toBeNull();
|
||||
});
|
||||
|
||||
it('rejette un claude_home non absolu ou inexistant/fichier (400)', async () => {
|
||||
for (const claudeHome of ['relative/.claude', join(dir, 'settings.db') /* fichier */, join(dir, 'nope')]) {
|
||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,29 @@ export interface CreateSessionRequest {
|
||||
/** binaire à lancer — défaut "claude" ; "bash" sert aux tests d'acceptation sans quota */
|
||||
command?: 'claude' | 'bash';
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/projects — crée un NOUVEAU dossier de projet `<root>/<name>` (le serveur ne crée
|
||||
* jamais de dossier ailleurs) puis y lance une session. Le `git init` est optionnel (case à cocher).
|
||||
* Réponse : `CreateProjectResponse`.
|
||||
*/
|
||||
export interface CreateProjectRequest {
|
||||
/** racine absolue où créer le projet (doit déjà exister). */
|
||||
root: string;
|
||||
/** nom du dossier de projet : un seul segment, sans séparateur ni `..`. */
|
||||
name: string;
|
||||
/** true → `git init` dans le nouveau dossier avant la session (défaut false). */
|
||||
gitInit?: boolean;
|
||||
/** binaire à lancer — défaut "claude" ; "bash" sert aux tests sans quota. */
|
||||
command?: 'claude' | 'bash';
|
||||
}
|
||||
export interface CreateProjectResponse {
|
||||
session: SessionSummary;
|
||||
/** chemin absolu du dossier de projet créé. */
|
||||
path: string;
|
||||
/** true si `git init` a réellement été exécuté. */
|
||||
gitInitialized: boolean;
|
||||
}
|
||||
export interface SessionsListResponse {
|
||||
sessions: SessionSummary[];
|
||||
}
|
||||
@@ -273,6 +296,19 @@ export interface ServerInfo {
|
||||
/** clé publique VAPID (sûre à exposer) ; null si push indisponible. */
|
||||
vapidPublicKey: string | null;
|
||||
vapidContact: string;
|
||||
/** racine effective de l'install Claude (~/.claude, --claude-home, ou réglage claude_home). */
|
||||
claudeHome: string;
|
||||
/** diagnostic de résolution du binaire `claude` (présence + exécutabilité). */
|
||||
claudeBin: ClaudeBinDiagnostic;
|
||||
}
|
||||
/** Diagnostic de résolution du CLI `claude` exposé en lecture dans les Réglages. */
|
||||
export interface ClaudeBinDiagnostic {
|
||||
/** chemin résolu du binaire, ou null si introuvable. */
|
||||
path: string | null;
|
||||
/** 'configured' = réglage explicite ; 'path' = trouvé via PATH ; null = introuvable. */
|
||||
source: 'configured' | 'path' | null;
|
||||
/** true si le binaire est présent et exécutable. */
|
||||
ok: boolean;
|
||||
}
|
||||
export interface SettingsResponse {
|
||||
/** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */
|
||||
@@ -281,6 +317,10 @@ export interface SettingsResponse {
|
||||
scanRoots: string[];
|
||||
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
|
||||
scanIntervalMin: number;
|
||||
/** chemin explicite du binaire `claude` ; null = auto-détection via PATH. Effet : prochaine session. */
|
||||
claudeBinPath: string | null;
|
||||
/** override de la racine ~/.claude ; null = défaut. Effet : au redémarrage du daemon. */
|
||||
claudeHome: string | null;
|
||||
};
|
||||
server: ServerInfo;
|
||||
}
|
||||
@@ -289,6 +329,10 @@ export interface UpdateSettingsRequest {
|
||||
scanRoots?: string[];
|
||||
/** intervalle du re-scan périodique en minutes (0–1440 ; 0 désactive). */
|
||||
scanIntervalMin?: number;
|
||||
/** chemin absolu du binaire `claude` (fichier exécutable) ; '' pour réinitialiser à l'auto-détection. */
|
||||
claudeBinPath?: string;
|
||||
/** override de la racine ~/.claude (répertoire existant) ; '' pour réinitialiser au défaut. */
|
||||
claudeHome?: string;
|
||||
}
|
||||
|
||||
// ---- Journal d'audit (conformité entreprise) ----
|
||||
|
||||
@@ -51,8 +51,12 @@ export const FLOW = {
|
||||
LAGGING_BYTES: 2 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
/** Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu) */
|
||||
export const REPLAY_TAIL_BYTES = 256 * 1024;
|
||||
/**
|
||||
* Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu).
|
||||
* 1 Mo (≈ 10–15k lignes) pour permettre de remonter une vraie conversation Claude dans le terminal ;
|
||||
* reste < LAGGING_BYTES (pas de faux lagging) et bien dans RING_CAPACITY.
|
||||
*/
|
||||
export const REPLAY_TAIL_BYTES = 1024 * 1024;
|
||||
|
||||
// ---- États de session (sous-ensemble P1 ; étendu en P3) ----
|
||||
export type SessionRuntimeStatus =
|
||||
|
||||
@@ -71,6 +71,20 @@ const { t } = useI18n();
|
||||
</template>
|
||||
{{ t('feat8Desc') }}
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard :title="t('feat9Title')">
|
||||
<template #icon>
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></svg>
|
||||
</template>
|
||||
{{ t('feat9Desc') }}
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard :title="t('feat10Title')">
|
||||
<template #icon>
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 10v6" /><path d="M9 13h6" /><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" /></svg>
|
||||
</template>
|
||||
{{ t('feat10Desc') }}
|
||||
</FeatureCard>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -32,6 +32,10 @@ export default {
|
||||
feat7Desc: 'Group repos and run one Claude session across all of them at once.',
|
||||
feat8Title: 'Local-first & secure',
|
||||
feat8Desc: 'Binds to localhost, hashes tokens, remote access via Tailscale.',
|
||||
feat9Title: 'Native VS Code extension',
|
||||
feat9Desc: 'Drive worktrees and attach sessions in native terminals, right inside your editor.',
|
||||
feat10Title: 'New project in one click',
|
||||
feat10Desc: 'Create a project folder anywhere (optional git init) and start a Claude session in it, from any device.',
|
||||
scAKicker: 'Worktree-first dashboard',
|
||||
scATitle: 'See what needs you — before anything stalls',
|
||||
scABody:
|
||||
|
||||
@@ -32,6 +32,10 @@ export default {
|
||||
feat7Desc: 'Regroupez des repos et pilotez-les via une seule session Claude, tous à la fois.',
|
||||
feat8Title: 'Local-first & sécurisé',
|
||||
feat8Desc: 'Se lie à localhost, hashe les tokens, accès distant via Tailscale.',
|
||||
feat9Title: 'Extension VS Code native',
|
||||
feat9Desc: 'Pilotez vos worktrees et attachez vos sessions dans des terminaux natifs, directement dans votre éditeur.',
|
||||
feat10Title: 'Nouveau projet en un clic',
|
||||
feat10Desc: "Créez un dossier de projet où vous voulez (git init optionnel) et lancez-y une session Claude, depuis n'importe quel appareil.",
|
||||
scAKicker: 'Dashboard worktree-first',
|
||||
scATitle: 'Voyez ce qui vous attend — avant que ça ne bloque',
|
||||
scABody:
|
||||
|
||||
2
packages/vscode/.gitignore
vendored
Normal file
2
packages/vscode/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
*.vsix
|
||||
9
packages/vscode/.vscodeignore
Normal file
9
packages/vscode/.vscodeignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# Tout est bundlé dans dist/extension.js par esbuild : on n'embarque que le bundle + la meta.
|
||||
src/**
|
||||
test/**
|
||||
node_modules/**
|
||||
esbuild.mjs
|
||||
tsconfig.json
|
||||
.gitignore
|
||||
**/*.ts
|
||||
**/*.map
|
||||
16
packages/vscode/CHANGELOG.md
Normal file
16
packages/vscode/CHANGELOG.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
Initial release — native VS Code integration for the Arboretum daemon:
|
||||
|
||||
- Real-time **Repositories** and **Groups** trees (repos → worktrees → sessions) over the daemon WebSocket.
|
||||
- **Native terminals** (`Pseudoterminal`) to attach/observe any session, reusing VS Code's rendering and
|
||||
scrollback.
|
||||
- **Status-bar** waiting counter and native **notifications** on the rising edge to `waiting`, with
|
||||
Yes/No answers via the `answer` command.
|
||||
- Git mutations from the tree: **create worktree, commit, push, promote**; start session / start group
|
||||
session; resume / fork / kill / hide.
|
||||
- **Workspace awareness**: reveal the worktree for the open folder; start a session or create a worktree
|
||||
for the current folder.
|
||||
- Token auth via `Authorization: Bearer` (REST + WebSocket), token stored in SecretStorage.
|
||||
21
packages/vscode/LICENSE
Normal file
21
packages/vscode/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Johan Leroy
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
73
packages/vscode/README.md
Normal file
73
packages/vscode/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Arboretum for VS Code
|
||||
|
||||
Pilot your git **worktrees** and **Claude Code sessions** from inside VS Code — a real native
|
||||
integration on top of the [Arboretum](https://git.lidge.fr/johanleroy/arboretum) daemon, not a webview.
|
||||
|
||||
- **Live tree** of Repositories → Worktrees → Sessions (and a Groups view), updated in real time over
|
||||
the daemon's WebSocket.
|
||||
- **Native terminals**: attach to any session in a real VS Code terminal (a `Pseudoterminal` bridges the
|
||||
daemon's PTY) — you get VS Code's own rendering, scrollback, copy/paste and links for free.
|
||||
- **Waiting alerts**: a status-bar counter and native notifications when a Claude session is waiting for
|
||||
input — answer **Yes/No** without even opening the terminal (uses the daemon's `answer` command).
|
||||
- **Git mutations** from the tree: create worktree, commit, push, promote to main.
|
||||
- **Workspace-aware**: the worktree matching your open folder is highlighted; start a session or create a
|
||||
worktree for the current folder in one command.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A running Arboretum daemon (`npx @johanleroy/git-arboretum`, or installed as a user service via
|
||||
`arboretum install`). The extension is a **client** — it does not start the daemon.
|
||||
- An access token. The bootstrap token is printed once on first daemon start; you can also create one in
|
||||
the Arboretum dashboard (**Settings → Tokens**).
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Start the daemon and copy a token.
|
||||
2. In VS Code, run **Arboretum: Sign In** (Command Palette) and paste the token. The token is validated
|
||||
against the daemon and stored in VS Code's encrypted **SecretStorage**.
|
||||
3. The **Arboretum** view container appears in the Activity Bar with the Repositories and Groups trees.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `arboretum.url` | `http://127.0.0.1:7317` | Base URL of the daemon (REST + WebSocket). |
|
||||
| `arboretum.showExternalSessions` | `false` | Also show Claude sessions started outside Arboretum (CLI). |
|
||||
| `arboretum.notifyOnWaiting` | `true` | Native notification when a session starts waiting for input. |
|
||||
|
||||
The extension authenticates with `Authorization: Bearer <token>` on both REST and the WebSocket upgrade.
|
||||
For remote access, point `arboretum.url` at your Tailscale Serve URL (`https://…`) — the WebSocket is
|
||||
derived automatically (`wss://`).
|
||||
|
||||
## Building & packaging (private VSIX)
|
||||
|
||||
The extension is bundled with esbuild (`@arboretum/shared` is inlined → the VSIX is self-contained).
|
||||
|
||||
```bash
|
||||
# from the monorepo root
|
||||
npm install
|
||||
npm run build:vscode # builds @arboretum/shared then bundles the extension
|
||||
cd packages/vscode && npx @vscode/vsce package --no-dependencies
|
||||
# → git-arboretum-0.1.0.vsix
|
||||
```
|
||||
|
||||
Install it with **Extensions: Install from VSIX…** in the Command Palette, or:
|
||||
|
||||
```bash
|
||||
code --install-extension git-arboretum-0.1.0.vsix # also: codium / cursor
|
||||
```
|
||||
|
||||
### Other distribution channels (optional)
|
||||
|
||||
- **Open VSX** (VSCodium, Cursor, Windsurf): `npx ovsx publish *.vsix -p <token>` after creating an
|
||||
Open VSX account and namespace.
|
||||
- **VS Code Marketplace**: create an Azure DevOps publisher + PAT, then `npx @vscode/vsce publish`.
|
||||
|
||||
A Gitea Actions workflow packages the VSIX automatically on a `vscode-vX.Y.Z` tag (see
|
||||
`.gitea/workflows/vscode-release.yml`) and attaches it to the release.
|
||||
|
||||
## Security
|
||||
|
||||
The extension never weakens the daemon's security model: it speaks the same authenticated protocol as the
|
||||
web dashboard. A Node client sends no `Origin` header, so it passes the daemon's strict Origin check while
|
||||
still requiring a valid token.
|
||||
28
packages/vscode/esbuild.mjs
Normal file
28
packages/vscode/esbuild.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
// Build de l'extension : un seul bundle CJS `dist/extension.js`.
|
||||
// esbuild résout `@arboretum/shared` via le symlink workspace et l'inline dans le bundle
|
||||
// (VSIX 100 % autonome, sans node_modules embarqué) ; seul `vscode` reste externe (fourni par l'hôte).
|
||||
import esbuild from 'esbuild';
|
||||
|
||||
const watch = process.argv.includes('--watch');
|
||||
|
||||
/** @type {import('esbuild').BuildOptions} */
|
||||
const options = {
|
||||
entryPoints: ['src/extension.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node18', // VSCode 1.85 embarque Electron/Node 18
|
||||
outfile: 'dist/extension.js',
|
||||
external: ['vscode'], // module fourni par l'hôte VSCode, jamais bundlé
|
||||
sourcemap: true,
|
||||
minify: !watch,
|
||||
logLevel: 'info',
|
||||
};
|
||||
|
||||
if (watch) {
|
||||
const ctx = await esbuild.context(options);
|
||||
await ctx.watch();
|
||||
console.log('[esbuild] watching…');
|
||||
} else {
|
||||
await esbuild.build(options);
|
||||
}
|
||||
8
packages/vscode/media/arboretum.svg
Normal file
8
packages/vscode/media/arboretum.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22v-6" />
|
||||
<path d="M12 16c0-3 2.5-3.5 4-5 1.4-1.4 1.5-3.6 1-5" />
|
||||
<path d="M12 16c0-3-2.5-3.5-4-5C6.6 9.6 6.5 7.4 7 6" />
|
||||
<circle cx="12" cy="4" r="1.6" />
|
||||
<circle cx="17" cy="6" r="1.6" />
|
||||
<circle cx="7" cy="6" r="1.6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
164
packages/vscode/package.json
Normal file
164
packages/vscode/package.json
Normal file
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"name": "git-arboretum",
|
||||
"displayName": "Arboretum",
|
||||
"description": "Pilot your git worktrees and Claude Code sessions from VS Code — native terminals, live tree, waiting alerts.",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"publisher": "johanleroy",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.lidge.fr/johanleroy/arboretum"
|
||||
},
|
||||
"categories": [
|
||||
"SCM Providers",
|
||||
"Other"
|
||||
],
|
||||
"keywords": [
|
||||
"git",
|
||||
"worktree",
|
||||
"claude",
|
||||
"terminal",
|
||||
"arboretum"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"activationEvents": [
|
||||
"onStartupFinished"
|
||||
],
|
||||
"contributes": {
|
||||
"configuration": {
|
||||
"title": "Arboretum",
|
||||
"properties": {
|
||||
"arboretum.url": {
|
||||
"type": "string",
|
||||
"default": "http://127.0.0.1:7317",
|
||||
"markdownDescription": "Base URL of the Arboretum daemon (REST + WebSocket). Defaults to the loopback bind."
|
||||
},
|
||||
"arboretum.showExternalSessions": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "Show Claude sessions discovered outside Arboretum (started from the CLI) in the trees. Off by default to avoid clutter."
|
||||
},
|
||||
"arboretum.notifyOnWaiting": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"markdownDescription": "Show a native notification when a Claude session starts waiting for input."
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
"id": "arboretum",
|
||||
"title": "Arboretum",
|
||||
"icon": "media/arboretum.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"arboretum": [
|
||||
{
|
||||
"id": "arboretum.repos",
|
||||
"name": "Repositories",
|
||||
"icon": "media/arboretum.svg"
|
||||
},
|
||||
{
|
||||
"id": "arboretum.groups",
|
||||
"name": "Groups",
|
||||
"icon": "media/arboretum.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"viewsWelcome": [
|
||||
{
|
||||
"view": "arboretum.repos",
|
||||
"contents": "Not connected to an Arboretum daemon.\n[Sign in](command:arboretum.signIn)\n\nMake sure the daemon is running (`npx @johanleroy/git-arboretum`) and paste a token.",
|
||||
"when": "!arboretum.connected"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{ "command": "arboretum.signIn", "title": "Sign In", "category": "Arboretum" },
|
||||
{ "command": "arboretum.signOut", "title": "Sign Out", "category": "Arboretum" },
|
||||
{ "command": "arboretum.refresh", "title": "Refresh", "category": "Arboretum", "icon": "$(refresh)" },
|
||||
{ "command": "arboretum.openDashboard", "title": "Open Dashboard in Browser", "category": "Arboretum", "icon": "$(globe)" },
|
||||
{ "command": "arboretum.attachSession", "title": "Attach Terminal", "category": "Arboretum", "icon": "$(terminal)" },
|
||||
{ "command": "arboretum.observeSession", "title": "Observe Terminal (read-only)", "category": "Arboretum", "icon": "$(eye)" },
|
||||
{ "command": "arboretum.answerSession", "title": "Answer Prompt…", "category": "Arboretum", "icon": "$(comment-discussion)" },
|
||||
{ "command": "arboretum.killSession", "title": "Kill Session", "category": "Arboretum", "icon": "$(trash)" },
|
||||
{ "command": "arboretum.hideSession", "title": "Hide Session", "category": "Arboretum", "icon": "$(eye-closed)" },
|
||||
{ "command": "arboretum.resumeSession", "title": "Resume Session", "category": "Arboretum", "icon": "$(debug-restart)" },
|
||||
{ "command": "arboretum.forkSession", "title": "Fork Session", "category": "Arboretum", "icon": "$(git-branch)" },
|
||||
{ "command": "arboretum.createWorktree", "title": "Create Worktree…", "category": "Arboretum", "icon": "$(add)" },
|
||||
{ "command": "arboretum.commitWorktree", "title": "Commit…", "category": "Arboretum", "icon": "$(git-commit)" },
|
||||
{ "command": "arboretum.pushWorktree", "title": "Push", "category": "Arboretum", "icon": "$(repo-push)" },
|
||||
{ "command": "arboretum.promoteWorktree", "title": "Promote to Main", "category": "Arboretum", "icon": "$(arrow-up)" },
|
||||
{ "command": "arboretum.startSession", "title": "Start Claude Session", "category": "Arboretum", "icon": "$(play)" },
|
||||
{ "command": "arboretum.startGroupSession", "title": "Start Group Session…", "category": "Arboretum", "icon": "$(play)" },
|
||||
{ "command": "arboretum.startSessionHere", "title": "Start Session in Current Folder", "category": "Arboretum" },
|
||||
{ "command": "arboretum.createWorktreeHere", "title": "Create Worktree for Current Folder…", "category": "Arboretum" },
|
||||
{ "command": "arboretum.revealWorktree", "title": "Reveal Worktree for Current Folder", "category": "Arboretum" }
|
||||
],
|
||||
"menus": {
|
||||
"view/title": [
|
||||
{ "command": "arboretum.refresh", "when": "view == arboretum.repos || view == arboretum.groups", "group": "navigation" },
|
||||
{ "command": "arboretum.openDashboard", "when": "view == arboretum.repos", "group": "navigation" },
|
||||
{ "command": "arboretum.signOut", "when": "view == arboretum.repos && arboretum.connected", "group": "1_auth" },
|
||||
{ "command": "arboretum.signIn", "when": "view == arboretum.repos && !arboretum.connected", "group": "1_auth" }
|
||||
],
|
||||
"view/item/context": [
|
||||
{ "command": "arboretum.createWorktree", "when": "viewItem == arboretum:repo", "group": "inline" },
|
||||
{ "command": "arboretum.startSession", "when": "viewItem == arboretum:repo", "group": "1_session" },
|
||||
{ "command": "arboretum.commitWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" },
|
||||
{ "command": "arboretum.pushWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" },
|
||||
{ "command": "arboretum.promoteWorktree", "when": "viewItem == arboretum:worktree", "group": "2_git" },
|
||||
{ "command": "arboretum.startSession", "when": "viewItem == arboretum:worktree", "group": "3_session" },
|
||||
{ "command": "arboretum.attachSession", "when": "viewItem =~ /arboretum:session:live/", "group": "inline" },
|
||||
{ "command": "arboretum.observeSession", "when": "viewItem =~ /arboretum:session:live/", "group": "1_term" },
|
||||
{ "command": "arboretum.answerSession", "when": "viewItem =~ /arboretum:session:live:.*waiting/", "group": "1_term" },
|
||||
{ "command": "arboretum.killSession", "when": "viewItem =~ /arboretum:session:live/", "group": "9_danger" },
|
||||
{ "command": "arboretum.resumeSession", "when": "viewItem =~ /arboretum:session:dead/", "group": "inline" },
|
||||
{ "command": "arboretum.forkSession", "when": "viewItem =~ /arboretum:session/", "group": "2_term" },
|
||||
{ "command": "arboretum.hideSession", "when": "viewItem =~ /arboretum:session:.*discovered/", "group": "9_danger" },
|
||||
{ "command": "arboretum.startGroupSession", "when": "viewItem == arboretum:group", "group": "inline" }
|
||||
],
|
||||
"commandPalette": [
|
||||
{ "command": "arboretum.refresh", "when": "arboretum.connected" },
|
||||
{ "command": "arboretum.signOut", "when": "arboretum.connected" },
|
||||
{ "command": "arboretum.attachSession", "when": "false" },
|
||||
{ "command": "arboretum.observeSession", "when": "false" },
|
||||
{ "command": "arboretum.answerSession", "when": "false" },
|
||||
{ "command": "arboretum.killSession", "when": "false" },
|
||||
{ "command": "arboretum.hideSession", "when": "false" },
|
||||
{ "command": "arboretum.resumeSession", "when": "false" },
|
||||
{ "command": "arboretum.forkSession", "when": "false" },
|
||||
{ "command": "arboretum.createWorktree", "when": "false" },
|
||||
{ "command": "arboretum.commitWorktree", "when": "false" },
|
||||
{ "command": "arboretum.pushWorktree", "when": "false" },
|
||||
{ "command": "arboretum.promoteWorktree", "when": "false" },
|
||||
{ "command": "arboretum.startSession", "when": "false" },
|
||||
{ "command": "arboretum.startGroupSession", "when": "false" },
|
||||
{ "command": "arboretum.revealWorktree", "when": "false" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"build": "npm run typecheck && node esbuild.mjs",
|
||||
"dev": "node esbuild.mjs --watch",
|
||||
"vscode:prepublish": "node esbuild.mjs",
|
||||
"package": "vsce package --no-dependencies"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arboretum/shared": "0.1.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"@vscode/vsce": "^3.2.0",
|
||||
"esbuild": "^0.21.0",
|
||||
"typescript": "^5.7.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
173
packages/vscode/src/api/rest-client.ts
Normal file
173
packages/vscode/src/api/rest-client.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
// Client REST typé du daemon Arboretum — module pur (pas d'import vscode), testable.
|
||||
// Auth par en-tête `Authorization: Bearer <token>` (le hook preValidation du serveur l'accepte
|
||||
// au même titre que le cookie). Un client Node n'envoie pas d'en-tête Origin → il passe le check
|
||||
// Origin strict côté serveur. Réutilise les types de `@arboretum/shared`.
|
||||
import type {
|
||||
ApiError,
|
||||
CommitWorktreeRequest,
|
||||
CreateGroupSessionRequest,
|
||||
CreateSessionRequest,
|
||||
CreateWorktreeRequest,
|
||||
CreateWorktreeResponse,
|
||||
GroupSessionResponse,
|
||||
GroupsListResponse,
|
||||
MeResponse,
|
||||
PromoteWorktreeRequest,
|
||||
PushWorktreeRequest,
|
||||
RepoBranchesResponse,
|
||||
ReposListResponse,
|
||||
SessionResponse,
|
||||
SessionsListResponse,
|
||||
StartRepoSessionRequest,
|
||||
WorktreeResponse,
|
||||
WorktreesListResponse,
|
||||
} from '@arboretum/shared';
|
||||
import { normalizeBaseUrl } from '../config.js';
|
||||
|
||||
/** Erreur normalisée portant le code/HTTP renvoyés par le serveur. */
|
||||
export class RestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RestError';
|
||||
}
|
||||
}
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
|
||||
export class RestClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null;
|
||||
|
||||
constructor(
|
||||
opts: { baseUrl: string; token?: string | null },
|
||||
private readonly fetchImpl: FetchLike = fetch,
|
||||
) {
|
||||
this.baseUrl = normalizeBaseUrl(opts.baseUrl);
|
||||
this.token = opts.token ?? null;
|
||||
}
|
||||
|
||||
setBaseUrl(url: string): void {
|
||||
this.baseUrl = normalizeBaseUrl(url);
|
||||
}
|
||||
|
||||
setToken(token: string | null): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
// ---- auth ----
|
||||
/** Valide le token courant ; lève RestError(401) si invalide. */
|
||||
me(): Promise<MeResponse> {
|
||||
return this.request<MeResponse>('GET', '/api/v1/auth/me');
|
||||
}
|
||||
|
||||
// ---- repos & worktrees ----
|
||||
listRepos(): Promise<ReposListResponse> {
|
||||
return this.request<ReposListResponse>('GET', '/api/v1/repos');
|
||||
}
|
||||
|
||||
listWorktrees(): Promise<WorktreesListResponse> {
|
||||
return this.request<WorktreesListResponse>('GET', '/api/v1/worktrees');
|
||||
}
|
||||
|
||||
getBranches(repoId: string): Promise<RepoBranchesResponse> {
|
||||
return this.request<RepoBranchesResponse>('GET', `/api/v1/repos/${encodeURIComponent(repoId)}/branches`);
|
||||
}
|
||||
|
||||
createWorktree(repoId: string, body: CreateWorktreeRequest): Promise<CreateWorktreeResponse> {
|
||||
return this.request<CreateWorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees`, body);
|
||||
}
|
||||
|
||||
commitWorktree(repoId: string, body: CommitWorktreeRequest): Promise<WorktreeResponse> {
|
||||
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/commit`, body);
|
||||
}
|
||||
|
||||
pushWorktree(repoId: string, body: PushWorktreeRequest): Promise<WorktreeResponse> {
|
||||
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/push`, body);
|
||||
}
|
||||
|
||||
promoteWorktree(repoId: string, body: PromoteWorktreeRequest): Promise<WorktreeResponse> {
|
||||
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/promote`, body);
|
||||
}
|
||||
|
||||
startRepoSession(repoId: string, body: StartRepoSessionRequest): Promise<SessionResponse> {
|
||||
return this.request<SessionResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/session`, body);
|
||||
}
|
||||
|
||||
// ---- sessions ----
|
||||
createSession(body: CreateSessionRequest): Promise<SessionResponse> {
|
||||
return this.request<SessionResponse>('POST', '/api/v1/sessions', body);
|
||||
}
|
||||
|
||||
listSessions(includeHidden = false): Promise<SessionsListResponse> {
|
||||
const q = includeHidden ? '?includeHidden=true' : '';
|
||||
return this.request<SessionsListResponse>('GET', `/api/v1/sessions${q}`);
|
||||
}
|
||||
|
||||
resumeSession(id: string): Promise<SessionResponse> {
|
||||
return this.request<SessionResponse>('POST', `/api/v1/sessions/${encodeURIComponent(id)}/resume`, {});
|
||||
}
|
||||
|
||||
forkSession(id: string): Promise<SessionResponse> {
|
||||
return this.request<SessionResponse>('POST', `/api/v1/sessions/${encodeURIComponent(id)}/fork`, {});
|
||||
}
|
||||
|
||||
hideSession(id: string): Promise<{ ok: true }> {
|
||||
return this.request<{ ok: true }>('POST', `/api/v1/sessions/${encodeURIComponent(id)}/hide`, {});
|
||||
}
|
||||
|
||||
killSession(id: string): Promise<{ ok: true }> {
|
||||
return this.request<{ ok: true }>('DELETE', `/api/v1/sessions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
// ---- groupes ----
|
||||
listGroups(): Promise<GroupsListResponse> {
|
||||
return this.request<GroupsListResponse>('GET', '/api/v1/groups');
|
||||
}
|
||||
|
||||
createGroupSession(groupId: string, body: CreateGroupSessionRequest): Promise<GroupSessionResponse> {
|
||||
return this.request<GroupSessionResponse>('POST', `/api/v1/groups/${encodeURIComponent(groupId)}/session`, body);
|
||||
}
|
||||
|
||||
// ---- interne ----
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (this.token) headers.Authorization = `Bearer ${this.token}`;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const init: RequestInit = { method, headers };
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchImpl(`${this.baseUrl}${path}`, init);
|
||||
} catch (err) {
|
||||
throw new RestError(`Cannot reach Arboretum at ${this.baseUrl}: ${(err as Error).message}`, 0, 'NETWORK');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let code = `HTTP_${res.status}`;
|
||||
let message = `${method} ${path} → ${res.status}`;
|
||||
try {
|
||||
const data = (await res.json()) as ApiError;
|
||||
if (data?.error) {
|
||||
code = data.error.code ?? code;
|
||||
message = data.error.message ?? message;
|
||||
}
|
||||
} catch {
|
||||
/* corps non-JSON : on garde le message générique */
|
||||
}
|
||||
throw new RestError(message, res.status, code);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
}
|
||||
425
packages/vscode/src/api/ws-client.ts
Normal file
425
packages/vscode/src/api/ws-client.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
// Client WebSocket multiplexé Node — portage du client web (packages/web/src/lib/ws-client.ts).
|
||||
// UNE connexion, plusieurs canaux ; handshake hello/hello_ok, sub des topics, corrélation FIFO des
|
||||
// 'attached', flow control par ACK (cumul d'octets PAR CANAL, remis à 0 à chaque RESYNC), reconnexion
|
||||
// avec backoff. Différences vs web :
|
||||
// - transport injecté (RawSocket) → testable sans réseau ; l'implémentation par défaut utilise `ws`
|
||||
// avec l'en-tête `Authorization: Bearer` posé à l'upgrade (le navigateur, lui, envoie le cookie).
|
||||
// - pas de callback de rendu (xterm) : on ACK les octets AU MOMENT où on les remet au sink (onDidWrite
|
||||
// d'un Pseudoterminal émet de façon synchrone). Un ACK traînant débouncé flushe le reliquat < 64 Ko.
|
||||
import {
|
||||
BINARY_FRAME,
|
||||
FLOW,
|
||||
PROTOCOL_VERSION,
|
||||
decodeBinaryFrame,
|
||||
type ClientMessage,
|
||||
type ServerMessage,
|
||||
} from '@arboretum/shared';
|
||||
import { wsUrlFromBase } from '../config.js';
|
||||
|
||||
export type WsStatus = 'idle' | 'connecting' | 'open' | 'reconnecting';
|
||||
export type DetachReason = 'client' | 'session_exit' | 'replaced';
|
||||
|
||||
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
||||
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
|
||||
export type GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_removed' }>;
|
||||
|
||||
/** Sink d'un canal : reçoit les octets BRUTS (le décodage UTF-8 est délégué au SessionBridge). */
|
||||
export interface AttachmentSink {
|
||||
data(payload: Uint8Array): void;
|
||||
reset(): void;
|
||||
detached(reason: DetachReason): void;
|
||||
controlChanged(controlling: boolean): void;
|
||||
}
|
||||
|
||||
export interface AttachOptions {
|
||||
sessionId: string;
|
||||
mode: 'interactive' | 'observer';
|
||||
cols: number;
|
||||
rows: number;
|
||||
sink: AttachmentSink;
|
||||
}
|
||||
|
||||
/** Transport minimal — abstrait pour les tests ; l'impl par défaut enveloppe `ws`. */
|
||||
export interface RawSocket {
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
onOpen(cb: () => void): void;
|
||||
onText(cb: (text: string) => void): void;
|
||||
onBinary(cb: (data: Uint8Array) => void): void;
|
||||
onClose(cb: () => void): void;
|
||||
onError(cb: (err: Error) => void): void;
|
||||
}
|
||||
|
||||
export type SocketFactory = (wsUrl: string, token: string) => RawSocket;
|
||||
|
||||
const BACKOFF_MIN_MS = 500;
|
||||
const BACKOFF_MAX_MS = 10_000;
|
||||
|
||||
export class Attachment {
|
||||
channel = -1;
|
||||
controlling = false;
|
||||
closed = false;
|
||||
cols: number;
|
||||
rows: number;
|
||||
epoch = 0;
|
||||
processedBytes = 0;
|
||||
lastAckBytes = 0;
|
||||
trailingAckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
pending: { resolve: (a: Attachment) => void; reject: (err: Error) => void } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly client: ArbWsClient,
|
||||
readonly sessionId: string,
|
||||
readonly mode: 'interactive' | 'observer',
|
||||
readonly sink: AttachmentSink,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
sendStdin(data: string): void {
|
||||
if (this.closed || this.channel < 0) return;
|
||||
this.client.sendControl({ type: 'stdin', channel: this.channel, data });
|
||||
}
|
||||
|
||||
/** Répond à un dialogue Claude sans clavier ; le serveur traduit en keystrokes. */
|
||||
answer(action: 'select' | 'confirm' | 'deny', optionN?: number): void {
|
||||
if (this.closed || this.channel < 0) return;
|
||||
this.client.sendControl(
|
||||
action === 'select' && optionN !== undefined
|
||||
? { type: 'answer', channel: this.channel, action, optionN }
|
||||
: { type: 'answer', channel: this.channel, action },
|
||||
);
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
if (this.closed || this.channel < 0 || !this.controlling) return;
|
||||
this.client.sendControl({ type: 'resize', channel: this.channel, cols, rows });
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.pending = null;
|
||||
this.client.releaseAttachment(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArbWsClientOptions {
|
||||
socketFactory: SocketFactory;
|
||||
onStatus?: (status: WsStatus) => void;
|
||||
onSession?: (e: SessionEvent) => void;
|
||||
onWorktree?: (e: WorktreeEvent) => void;
|
||||
onGroup?: (e: GroupEvent) => void;
|
||||
/** appelé au premier hello_ok après (re)connexion → re-seed du store via REST. */
|
||||
onReady?: () => void;
|
||||
log?: (msg: string) => void;
|
||||
}
|
||||
|
||||
export class ArbWsClient {
|
||||
status: WsStatus = 'idle';
|
||||
|
||||
private baseUrl = '';
|
||||
private token = '';
|
||||
private socket: RawSocket | null = null;
|
||||
private ready = false;
|
||||
private stopped = true;
|
||||
private backoffMs = BACKOFF_MIN_MS;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly attachments = new Set<Attachment>();
|
||||
private readonly byChannel = new Map<number, Attachment>();
|
||||
private awaitingAttached: Attachment[] = [];
|
||||
|
||||
constructor(private readonly opts: ArbWsClientOptions) {}
|
||||
|
||||
/** (Re)configure l'URL/token ; si la connexion était active, elle est relancée. */
|
||||
configure(baseUrl: string, token: string): void {
|
||||
const wasActive = !this.stopped;
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = token;
|
||||
if (wasActive) {
|
||||
this.hardReset();
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.stopped = false;
|
||||
if (this.socket || this.reconnectTimer) return;
|
||||
if (!this.token) return; // pas authentifié : on n'ouvre rien
|
||||
this.setStatus('connecting');
|
||||
this.openSocket();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.stopped = true;
|
||||
this.hardReset();
|
||||
this.setStatus('idle');
|
||||
}
|
||||
|
||||
/** résout sur le prochain 'attached' corrélé ; rejette si la session est introuvable/terminée */
|
||||
attach(opts: AttachOptions): Promise<Attachment> {
|
||||
const att = new Attachment(this, opts.sessionId, opts.mode, opts.sink, opts.cols, opts.rows);
|
||||
const promise = new Promise<Attachment>((resolve, reject) => {
|
||||
att.pending = { resolve, reject };
|
||||
});
|
||||
this.attachments.add(att);
|
||||
this.connect();
|
||||
if (this.ready) this.sendAttach(att);
|
||||
return promise;
|
||||
}
|
||||
|
||||
sendControl(msg: ClientMessage): void {
|
||||
if (!this.ready || !this.socket) return;
|
||||
try {
|
||||
this.socket.send(JSON.stringify(msg));
|
||||
} catch {
|
||||
/* socket en cours de fermeture : la reconnexion rejouera */
|
||||
}
|
||||
}
|
||||
|
||||
releaseAttachment(att: Attachment): void {
|
||||
this.attachments.delete(att);
|
||||
if (att.trailingAckTimer) {
|
||||
clearTimeout(att.trailingAckTimer);
|
||||
att.trailingAckTimer = null;
|
||||
}
|
||||
if (att.channel >= 0) {
|
||||
this.byChannel.delete(att.channel);
|
||||
this.sendControl({ type: 'detach', channel: att.channel });
|
||||
att.channel = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- interne ----
|
||||
|
||||
private setStatus(s: WsStatus): void {
|
||||
this.status = s;
|
||||
this.opts.onStatus?.(s);
|
||||
}
|
||||
|
||||
/** ferme la socket courante et invalide tous les états sans toucher au flag stopped. */
|
||||
private hardReset(): void {
|
||||
this.clearReconnectTimer();
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
this.ready = false;
|
||||
for (const att of this.attachments) {
|
||||
if (att.trailingAckTimer) {
|
||||
clearTimeout(att.trailingAckTimer);
|
||||
att.trailingAckTimer = null;
|
||||
}
|
||||
att.pending?.reject(new Error('disconnected'));
|
||||
att.pending = null;
|
||||
att.closed = true;
|
||||
att.sink.detached('client');
|
||||
}
|
||||
this.attachments.clear();
|
||||
this.byChannel.clear();
|
||||
this.awaitingAttached = [];
|
||||
socket?.close();
|
||||
}
|
||||
|
||||
private openSocket(): void {
|
||||
const socket = this.opts.socketFactory(wsUrlFromBase(this.baseUrl), this.token);
|
||||
this.socket = socket;
|
||||
socket.onOpen(() => {
|
||||
const hello: ClientMessage = { type: 'hello', protocol: PROTOCOL_VERSION };
|
||||
socket.send(JSON.stringify(hello));
|
||||
});
|
||||
socket.onText((text) => this.handleText(text));
|
||||
socket.onBinary((data) => this.handleBinary(data));
|
||||
socket.onClose(() => this.handleClose(socket));
|
||||
socket.onError((err) => this.opts.log?.(`ws error: ${err.message}`));
|
||||
}
|
||||
|
||||
private handleClose(socket: RawSocket): void {
|
||||
if (this.socket !== socket) return;
|
||||
this.socket = null;
|
||||
this.ready = false;
|
||||
this.byChannel.clear();
|
||||
this.awaitingAttached = [];
|
||||
for (const att of this.attachments) {
|
||||
att.channel = -1;
|
||||
att.epoch += 1;
|
||||
if (att.trailingAckTimer) {
|
||||
clearTimeout(att.trailingAckTimer);
|
||||
att.trailingAckTimer = null;
|
||||
}
|
||||
}
|
||||
if (this.stopped) {
|
||||
this.setStatus('idle');
|
||||
return;
|
||||
}
|
||||
this.setStatus('reconnecting');
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.clearReconnectTimer();
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (!this.stopped) this.openSocket();
|
||||
}, this.backoffMs);
|
||||
this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sendAttach(att: Attachment): void {
|
||||
this.awaitingAttached.push(att);
|
||||
this.sendControl({ type: 'attach', sessionId: att.sessionId, mode: att.mode, cols: att.cols, rows: att.rows });
|
||||
}
|
||||
|
||||
private handleText(text: string): void {
|
||||
let msg: ServerMessage;
|
||||
try {
|
||||
msg = JSON.parse(text) as ServerMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.handleServerMessage(msg);
|
||||
}
|
||||
|
||||
private handleBinary(data: Uint8Array): void {
|
||||
if (data.byteLength < BINARY_FRAME.HEADER_BYTES) return;
|
||||
const frame = decodeBinaryFrame(data);
|
||||
const att = this.byChannel.get(frame.channel);
|
||||
if (!att || att.closed) return;
|
||||
|
||||
if (frame.type === BINARY_FRAME.RESYNC) {
|
||||
// le serveur repart de sentBytes=0 : reset terminal, replay, compteurs à zéro (le replay ne compte pas)
|
||||
att.epoch += 1;
|
||||
att.processedBytes = 0;
|
||||
att.lastAckBytes = 0;
|
||||
att.sink.reset();
|
||||
if (frame.payload.byteLength > 0) att.sink.data(frame.payload);
|
||||
this.scheduleTrailingAck(att, att.epoch);
|
||||
return;
|
||||
}
|
||||
if (frame.type !== BINARY_FRAME.OUTPUT) return;
|
||||
|
||||
// Émission synchrone vers le sink (onDidWrite) → on ACK directement les octets remis.
|
||||
const length = frame.payload.byteLength;
|
||||
att.sink.data(frame.payload);
|
||||
if (att.channel < 0) return;
|
||||
att.processedBytes += length;
|
||||
if (att.processedBytes - att.lastAckBytes >= FLOW.ACK_EVERY_BYTES) {
|
||||
att.lastAckBytes = att.processedBytes;
|
||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||
}
|
||||
// Filet : flushe le reliquat < ACK_EVERY_BYTES pour que le serveur sorte toujours de pause.
|
||||
this.scheduleTrailingAck(att, att.epoch);
|
||||
}
|
||||
|
||||
private scheduleTrailingAck(att: Attachment, epoch: number): void {
|
||||
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
|
||||
att.trailingAckTimer = setTimeout(() => {
|
||||
att.trailingAckTimer = null;
|
||||
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
|
||||
if (att.processedBytes > att.lastAckBytes) {
|
||||
att.lastAckBytes = att.processedBytes;
|
||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
private handleServerMessage(msg: ServerMessage): void {
|
||||
switch (msg.type) {
|
||||
case 'hello_ok': {
|
||||
this.ready = true;
|
||||
this.backoffMs = BACKOFF_MIN_MS;
|
||||
this.setStatus('open');
|
||||
this.sendControl({ type: 'sub', topics: ['sessions', 'worktrees', 'groups'] });
|
||||
for (const att of this.attachments) {
|
||||
if (!att.closed) this.sendAttach(att);
|
||||
}
|
||||
this.opts.onReady?.();
|
||||
return;
|
||||
}
|
||||
case 'attached': {
|
||||
const att = this.awaitingAttached.shift();
|
||||
if (!att) return;
|
||||
if (att.closed) {
|
||||
this.sendControl({ type: 'detach', channel: msg.channel });
|
||||
return;
|
||||
}
|
||||
const wasControlling = att.controlling;
|
||||
att.channel = msg.channel;
|
||||
att.controlling = msg.controlling;
|
||||
this.byChannel.set(msg.channel, att);
|
||||
if (att.pending) {
|
||||
att.pending.resolve(att);
|
||||
att.pending = null;
|
||||
} else if (wasControlling !== msg.controlling) {
|
||||
att.sink.controlChanged(msg.controlling);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'detached': {
|
||||
const att = this.byChannel.get(msg.channel);
|
||||
if (!att) return;
|
||||
this.byChannel.delete(msg.channel);
|
||||
att.channel = -1;
|
||||
if (msg.reason === 'client') return;
|
||||
att.closed = true;
|
||||
this.attachments.delete(att);
|
||||
att.sink.detached(msg.reason);
|
||||
return;
|
||||
}
|
||||
case 'control_changed': {
|
||||
const att = this.byChannel.get(msg.channel);
|
||||
if (!att || att.closed) return;
|
||||
att.controlling = msg.controlling;
|
||||
att.sink.controlChanged(msg.controlling);
|
||||
return;
|
||||
}
|
||||
case 'session_update':
|
||||
case 'session_exit':
|
||||
this.opts.onSession?.(msg);
|
||||
return;
|
||||
case 'repo_update':
|
||||
case 'repo_removed':
|
||||
case 'worktree_update':
|
||||
case 'worktree_removed':
|
||||
this.opts.onWorktree?.(msg);
|
||||
return;
|
||||
case 'group_update':
|
||||
case 'group_removed':
|
||||
this.opts.onGroup?.(msg);
|
||||
return;
|
||||
case 'error': {
|
||||
if (msg.channel !== undefined) {
|
||||
this.opts.log?.(`channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
||||
return;
|
||||
}
|
||||
if ((msg.code === 'NOT_FOUND' || msg.code === 'SESSION_EXITED') && this.awaitingAttached.length > 0) {
|
||||
const att = this.awaitingAttached.shift();
|
||||
if (!att) return;
|
||||
this.attachments.delete(att);
|
||||
if (att.pending) {
|
||||
att.pending.reject(new Error(msg.code));
|
||||
att.pending = null;
|
||||
} else if (!att.closed) {
|
||||
att.closed = true;
|
||||
att.sink.detached('session_exit');
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.opts.log?.(`${msg.code} — ${msg.message}`);
|
||||
return;
|
||||
}
|
||||
case 'pong':
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
packages/vscode/src/api/ws-socket.ts
Normal file
33
packages/vscode/src/api/ws-socket.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Implémentation par défaut du transport RawSocket, basée sur le paquet `ws` (Node).
|
||||
// L'auth se fait en posant `Authorization: Bearer <token>` dans les en-têtes de l'upgrade HTTP
|
||||
// (un navigateur enverrait le cookie ; un client Node passe le token ici). Isolé du cœur ws-client
|
||||
// pour que celui-ci reste injectable et testable sans réseau.
|
||||
import WebSocket, { type RawData } from 'ws';
|
||||
import type { RawSocket, SocketFactory } from './ws-client.js';
|
||||
|
||||
/** Normalise un RawData (Buffer | ArrayBuffer | Buffer[]) en Uint8Array copiée (offset 0). */
|
||||
function toUint8(data: RawData): Uint8Array {
|
||||
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data));
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
||||
// Buffer (sous-classe de Uint8Array) : copie pour ne pas dépendre du buffer poolé réutilisé par ws.
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
|
||||
export const wsSocketFactory: SocketFactory = (wsUrl: string, token: string): RawSocket => {
|
||||
const socket = new WebSocket(wsUrl, { headers: { Authorization: `Bearer ${token}` } });
|
||||
return {
|
||||
send: (d) => socket.send(d),
|
||||
close: () => socket.close(),
|
||||
onOpen: (cb) => socket.on('open', cb),
|
||||
onText: (cb) =>
|
||||
socket.on('message', (data: RawData, isBinary: boolean) => {
|
||||
if (!isBinary) cb(toUint8(data).toString());
|
||||
}),
|
||||
onBinary: (cb) =>
|
||||
socket.on('message', (data: RawData, isBinary: boolean) => {
|
||||
if (isBinary) cb(toUint8(data));
|
||||
}),
|
||||
onClose: (cb) => socket.on('close', () => cb()),
|
||||
onError: (cb) => socket.on('error', (err: Error) => cb(err)),
|
||||
};
|
||||
};
|
||||
22
packages/vscode/src/auth/auth.ts
Normal file
22
packages/vscode/src/auth/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Stockage du token dans le SecretStorage de VS Code (chiffré par l'hôte). Les tokens du daemon sont
|
||||
// stockés hashés côté serveur et le bootstrap ne s'affiche qu'une fois → on ne peut pas « lire » un
|
||||
// token existant : l'utilisateur le saisit une fois (commande Sign In), on le conserve ici.
|
||||
import type { SecretStorage } from 'vscode';
|
||||
|
||||
const TOKEN_KEY = 'arboretum.token';
|
||||
|
||||
export class Auth {
|
||||
constructor(private readonly secrets: SecretStorage) {}
|
||||
|
||||
getToken(): Thenable<string | undefined> {
|
||||
return this.secrets.get(TOKEN_KEY);
|
||||
}
|
||||
|
||||
storeToken(token: string): Thenable<void> {
|
||||
return this.secrets.store(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
clearToken(): Thenable<void> {
|
||||
return this.secrets.delete(TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
299
packages/vscode/src/commands.ts
Normal file
299
packages/vscode/src/commands.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
// Enregistrement de toutes les commandes de l'extension. Les handlers contextuels reçoivent le nœud
|
||||
// d'arbre cliqué (ou via menu) et narrowent dessus ; les mutations passent par le RestClient et l'état
|
||||
// se met à jour via les events WS (pas de refetch manuel, sauf hide qui sort la session de la liste).
|
||||
import * as vscode from 'vscode';
|
||||
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
import { RestError, type RestClient } from './api/rest-client.js';
|
||||
import type { ArbWsClient, AttachmentSink } from './api/ws-client.js';
|
||||
import type { Store } from './state/store.js';
|
||||
import type { SessionTerminalManager } from './terminal/session-pty.js';
|
||||
import type { WorkspaceMapper } from './workspace.js';
|
||||
import type { AnyNode } from './views/nodes.js';
|
||||
|
||||
export interface CommandDeps {
|
||||
store: Store;
|
||||
rest: RestClient;
|
||||
ws: ArbWsClient;
|
||||
terminals: SessionTerminalManager;
|
||||
workspace: WorkspaceMapper;
|
||||
signIn(): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
reseed(): Promise<void>;
|
||||
}
|
||||
|
||||
// ---- extraction des nœuds ----
|
||||
function asSession(node: AnyNode | undefined): SessionSummary | undefined {
|
||||
return node && (node.kind === 'session' || node.kind === 'group-session') ? node.session : undefined;
|
||||
}
|
||||
function asWorktree(node: AnyNode | undefined): { repoId: string; worktree: WorktreeSummary } | undefined {
|
||||
return node?.kind === 'worktree' ? { repoId: node.repoId, worktree: node.worktree } : undefined;
|
||||
}
|
||||
function asRepo(node: AnyNode | undefined): RepoSummary | undefined {
|
||||
return node?.kind === 'repo' ? node.repo : undefined;
|
||||
}
|
||||
function asGroup(node: AnyNode | undefined): GroupSummary | undefined {
|
||||
return node?.kind === 'group' ? node.group : undefined;
|
||||
}
|
||||
|
||||
/** Exécute une action en affichant proprement une erreur REST (code + message serveur). */
|
||||
async function run(label: string, fn: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
const msg = err instanceof RestError ? `${err.code}: ${err.message}` : (err as Error).message;
|
||||
void vscode.window.showErrorMessage(`Arboretum — ${label}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Répond à un dialogue : via le terminal ouvert si présent, sinon par une attache éphémère. */
|
||||
export async function answerSession(
|
||||
deps: CommandDeps,
|
||||
session: SessionSummary,
|
||||
action: 'select' | 'confirm' | 'deny',
|
||||
optionN?: number,
|
||||
): Promise<void> {
|
||||
const open = deps.terminals.getBridge(session.id);
|
||||
if (open) {
|
||||
open.answer(action, optionN);
|
||||
return;
|
||||
}
|
||||
const noop: AttachmentSink = { data: () => {}, reset: () => {}, detached: () => {}, controlChanged: () => {} };
|
||||
const att = await deps.ws.attach({ sessionId: session.id, mode: 'interactive', cols: 80, rows: 24, sink: noop });
|
||||
att.answer(action, optionN);
|
||||
setTimeout(() => att.detach(), 600); // laisse le serveur traiter avant de fermer le canal éphémère
|
||||
}
|
||||
|
||||
export function registerCommands(context: vscode.ExtensionContext, deps: CommandDeps): void {
|
||||
const { store, rest, terminals, workspace } = deps;
|
||||
|
||||
const cmd = (id: string, handler: (...args: unknown[]) => unknown): void => {
|
||||
context.subscriptions.push(vscode.commands.registerCommand(id, handler));
|
||||
};
|
||||
|
||||
// ---- auth / global ----
|
||||
cmd('arboretum.signIn', () => deps.signIn());
|
||||
cmd('arboretum.signOut', () => deps.signOut());
|
||||
cmd('arboretum.refresh', () => run('refresh', () => deps.reseed()));
|
||||
cmd('arboretum.openDashboard', () => {
|
||||
void vscode.env.openExternal(vscode.Uri.parse(rest.url));
|
||||
});
|
||||
|
||||
// ---- terminaux (Phase B) ----
|
||||
cmd('arboretum.attachSession', (node) => {
|
||||
const s = asSession(node as AnyNode);
|
||||
if (s) terminals.open(s, 'interactive');
|
||||
});
|
||||
cmd('arboretum.observeSession', (node) => {
|
||||
const s = asSession(node as AnyNode);
|
||||
if (s) terminals.open(s, 'observer');
|
||||
});
|
||||
cmd('arboretum.showWaiting', async () => {
|
||||
const waiting = store.waitingSessions();
|
||||
if (waiting.length === 0) {
|
||||
void vscode.window.showInformationMessage('Arboretum: no session waiting.');
|
||||
return;
|
||||
}
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
waiting.map((s) => ({ label: s.title?.trim() || s.command, description: s.waitingFor ?? s.cwd, session: s })),
|
||||
{ placeHolder: 'Sessions waiting for input' },
|
||||
);
|
||||
if (pick) terminals.open(pick.session, 'interactive');
|
||||
});
|
||||
|
||||
// ---- answer (Phase C) ----
|
||||
cmd('arboretum.answerSession', async (node) => {
|
||||
const s = asSession(node as AnyNode);
|
||||
if (!s) return;
|
||||
const dialogOptions = s.dialog?.options ?? [];
|
||||
if (dialogOptions.length > 0) {
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
dialogOptions.map((o) => ({ label: o.label, option: o })),
|
||||
{ placeHolder: s.dialog?.waitingFor ?? 'Choose an option' },
|
||||
);
|
||||
if (pick) await run('answer', () => answerSession(deps, s, 'select', pick.option.n));
|
||||
return;
|
||||
}
|
||||
const pick = await vscode.window.showQuickPick(['Yes', 'No'], { placeHolder: s.waitingFor ?? 'Answer the prompt' });
|
||||
if (pick) await run('answer', () => answerSession(deps, s, pick === 'Yes' ? 'confirm' : 'deny'));
|
||||
});
|
||||
|
||||
// ---- sessions ----
|
||||
cmd('arboretum.killSession', async (node) => {
|
||||
const s = asSession(node as AnyNode);
|
||||
if (!s) return;
|
||||
const ok = await vscode.window.showWarningMessage(`Kill session "${s.title?.trim() || s.command}"?`, { modal: true }, 'Kill');
|
||||
if (ok === 'Kill') await run('kill', async () => void (await rest.killSession(s.id)));
|
||||
});
|
||||
cmd('arboretum.hideSession', (node) => {
|
||||
const s = asSession(node as AnyNode);
|
||||
if (!s) return;
|
||||
void run('hide', async () => {
|
||||
await rest.hideSession(s.id);
|
||||
await deps.reseed(); // la session masquée sort de la liste (listSessions sans includeHidden)
|
||||
});
|
||||
});
|
||||
cmd('arboretum.resumeSession', (node) => {
|
||||
const s = asSession(node as AnyNode);
|
||||
if (!s) return;
|
||||
void run('resume', async () => {
|
||||
const res = await rest.resumeSession(s.id);
|
||||
terminals.open(res.session, 'interactive');
|
||||
});
|
||||
});
|
||||
cmd('arboretum.forkSession', (node) => {
|
||||
const s = asSession(node as AnyNode);
|
||||
if (!s) return;
|
||||
void run('fork', async () => {
|
||||
const res = await rest.forkSession(s.id);
|
||||
terminals.open(res.session, 'interactive');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- worktrees (Phase C) ----
|
||||
cmd('arboretum.createWorktree', (node) => {
|
||||
const repo = asRepo(node as AnyNode);
|
||||
if (repo) void createWorktreeFlow(deps, repo.id);
|
||||
});
|
||||
cmd('arboretum.commitWorktree', (node) => {
|
||||
const wt = asWorktree(node as AnyNode);
|
||||
if (!wt) return;
|
||||
void run('commit', async () => {
|
||||
const message = await vscode.window.showInputBox({ prompt: 'Commit message', placeHolder: 'Describe your changes' });
|
||||
if (!message) return;
|
||||
await rest.commitWorktree(wt.repoId, { path: wt.worktree.path, message });
|
||||
void vscode.window.showInformationMessage('Arboretum: committed.');
|
||||
});
|
||||
});
|
||||
cmd('arboretum.pushWorktree', (node) => {
|
||||
const wt = asWorktree(node as AnyNode);
|
||||
if (!wt) return;
|
||||
void vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Arboretum: pushing…' }, () =>
|
||||
run('push', async () => {
|
||||
await rest.pushWorktree(wt.repoId, { path: wt.worktree.path });
|
||||
void vscode.window.showInformationMessage('Arboretum: pushed.');
|
||||
}),
|
||||
);
|
||||
});
|
||||
cmd('arboretum.promoteWorktree', (node) => {
|
||||
const wt = asWorktree(node as AnyNode);
|
||||
if (wt) void promoteWorktreeFlow(deps, wt.repoId, wt.worktree);
|
||||
});
|
||||
|
||||
// ---- démarrage de session ----
|
||||
cmd('arboretum.startSession', (node) => {
|
||||
const wt = asWorktree(node as AnyNode);
|
||||
if (wt) {
|
||||
void run('start session', async () => {
|
||||
const res = await rest.createSession({ cwd: wt.worktree.path, command: 'claude' });
|
||||
terminals.open(res.session, 'interactive');
|
||||
});
|
||||
return;
|
||||
}
|
||||
const repo = asRepo(node as AnyNode);
|
||||
if (repo) {
|
||||
void run('start session', async () => {
|
||||
const res = await rest.startRepoSession(repo.id, { command: 'claude' });
|
||||
terminals.open(res.session, 'interactive');
|
||||
});
|
||||
}
|
||||
});
|
||||
cmd('arboretum.startGroupSession', (node) => {
|
||||
const group = asGroup(node as AnyNode);
|
||||
if (group) void startGroupSessionFlow(deps, group);
|
||||
});
|
||||
|
||||
// ---- conscience du workspace (Phase D) ----
|
||||
cmd('arboretum.startSessionHere', () => {
|
||||
const repoId = workspace.currentRepoId();
|
||||
if (!repoId) {
|
||||
void vscode.window.showWarningMessage('Arboretum: the current folder is not a known repo/worktree.');
|
||||
return;
|
||||
}
|
||||
const folder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!folder) return;
|
||||
void run('start session', async () => {
|
||||
const res = await rest.createSession({ cwd: folder.uri.fsPath, command: 'claude' });
|
||||
terminals.open(res.session, 'interactive');
|
||||
});
|
||||
});
|
||||
cmd('arboretum.createWorktreeHere', () => {
|
||||
const repoId = workspace.currentRepoId();
|
||||
if (!repoId) {
|
||||
void vscode.window.showWarningMessage('Arboretum: the current folder is not a known repo.');
|
||||
return;
|
||||
}
|
||||
void createWorktreeFlow(deps, repoId);
|
||||
});
|
||||
cmd('arboretum.revealWorktree', () => workspace.revealCurrent());
|
||||
}
|
||||
|
||||
// ---- flows interactifs ----
|
||||
|
||||
async function createWorktreeFlow(deps: CommandDeps, repoId: string): Promise<void> {
|
||||
await run('create worktree', async () => {
|
||||
const branches = await deps.rest.getBranches(repoId);
|
||||
const existing = [...new Set([...branches.local, ...branches.remote.map((b) => b.replace(/^origin\//, ''))])].sort();
|
||||
const NEW = '$(add) New branch…';
|
||||
const items = [NEW, ...existing];
|
||||
const choice = await vscode.window.showQuickPick(items, { placeHolder: 'Branch for the new worktree (auto: checkout if exists, else create)' });
|
||||
if (!choice) return;
|
||||
let branch = choice;
|
||||
if (choice === NEW) {
|
||||
const input = await vscode.window.showInputBox({ prompt: 'New branch name' });
|
||||
if (!input) return;
|
||||
branch = input.trim();
|
||||
}
|
||||
if (!branch) return;
|
||||
const res = await deps.rest.createWorktree(repoId, { branch, mode: 'auto' });
|
||||
void vscode.window.showInformationMessage(`Arboretum: worktree ${res.action} for "${branch}".`);
|
||||
});
|
||||
}
|
||||
|
||||
async function promoteWorktreeFlow(deps: CommandDeps, repoId: string, worktree: WorktreeSummary): Promise<void> {
|
||||
const label = worktree.branch ?? worktree.path;
|
||||
const confirm = await vscode.window.showWarningMessage(
|
||||
`Promote "${label}" to the repo's main checkout? The worktree is removed (branch kept).`,
|
||||
{ modal: true },
|
||||
'Promote',
|
||||
);
|
||||
if (confirm !== 'Promote') return;
|
||||
try {
|
||||
await deps.rest.promoteWorktree(repoId, { path: worktree.path });
|
||||
void vscode.window.showInformationMessage(`Arboretum: "${label}" promoted to main.`);
|
||||
} catch (err) {
|
||||
if (err instanceof RestError && err.status === 409) {
|
||||
const force = await vscode.window.showWarningMessage(
|
||||
`Arboretum: working tree is dirty (${err.message}). Promote anyway?`,
|
||||
{ modal: true },
|
||||
'Force promote',
|
||||
);
|
||||
if (force === 'Force promote') {
|
||||
await run('promote', async () => {
|
||||
await deps.rest.promoteWorktree(repoId, { path: worktree.path, force: true });
|
||||
void vscode.window.showInformationMessage(`Arboretum: "${label}" promoted (forced).`);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const msg = err instanceof RestError ? `${err.code}: ${err.message}` : (err as Error).message;
|
||||
void vscode.window.showErrorMessage(`Arboretum — promote: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function startGroupSessionFlow(deps: CommandDeps, group: GroupSummary): Promise<void> {
|
||||
await run('start group session', async () => {
|
||||
const branch = await vscode.window.showInputBox({
|
||||
prompt: `Branch to cover in each repo of "${group.label}" (leave empty for the main checkouts)`,
|
||||
placeHolder: 'feature/cross-repo (optional)',
|
||||
});
|
||||
if (branch === undefined) return; // annulé (chaîne vide = checkouts principaux)
|
||||
const body = branch.trim() ? { command: 'claude' as const, branch: branch.trim() } : { command: 'claude' as const };
|
||||
const res = await deps.rest.createGroupSession(group.id, body);
|
||||
deps.terminals.open(res.session, 'interactive');
|
||||
if (res.skipped.length > 0) {
|
||||
void vscode.window.showWarningMessage(
|
||||
`Arboretum: ${res.skipped.length} repo(s) skipped — ${res.skipped.map((s) => s.reason).join('; ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
21
packages/vscode/src/config.ts
Normal file
21
packages/vscode/src/config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Lecture des réglages `arboretum.*` — module pur (aucun import vscode) pour rester testable.
|
||||
// L'extension fournit un `ConfigSnapshot` lu depuis workspace.getConfiguration ; ce module
|
||||
// normalise/dérive les valeurs (notamment l'URL WebSocket à partir de l'URL HTTP).
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
url: string;
|
||||
showExternalSessions: boolean;
|
||||
notifyOnWaiting: boolean;
|
||||
}
|
||||
|
||||
/** Base REST normalisée (sans slash final) ; `http://127.0.0.1:7317` par défaut. */
|
||||
export function normalizeBaseUrl(url: string): string {
|
||||
const trimmed = (url || '').trim().replace(/\/+$/, '');
|
||||
return trimmed || 'http://127.0.0.1:7317';
|
||||
}
|
||||
|
||||
/** Dérive l'URL du endpoint WebSocket `/ws` (http→ws, https→wss). */
|
||||
export function wsUrlFromBase(baseUrl: string): string {
|
||||
const base = normalizeBaseUrl(baseUrl);
|
||||
return `${base.replace(/^http/, 'ws')}/ws`;
|
||||
}
|
||||
152
packages/vscode/src/extension.ts
Normal file
152
packages/vscode/src/extension.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// Point d'entrée de l'extension : câble auth ↔ REST ↔ WS ↔ store ↔ surfaces natives (arbres, terminaux,
|
||||
// status bar, notifications, workspace). C'est le SEUL gros consommateur de l'API vscode avec les vues ;
|
||||
// toute la logique réutilisable (protocole, état) vit dans des modules purs testables.
|
||||
import * as vscode from 'vscode';
|
||||
import { Auth } from './auth/auth.js';
|
||||
import { RestClient } from './api/rest-client.js';
|
||||
import { ArbWsClient } from './api/ws-client.js';
|
||||
import { wsSocketFactory } from './api/ws-socket.js';
|
||||
import { Store } from './state/store.js';
|
||||
import { SessionTerminalManager } from './terminal/session-pty.js';
|
||||
import { ReposTreeProvider } from './views/repos-tree.js';
|
||||
import { GroupsTreeProvider } from './views/groups-tree.js';
|
||||
import { StatusBar } from './status-bar.js';
|
||||
import { WaitingNotifier } from './notifications.js';
|
||||
import { WorkspaceMapper } from './workspace.js';
|
||||
import { answerSession, registerCommands } from './commands.js';
|
||||
|
||||
function readUrl(): string {
|
||||
return vscode.workspace.getConfiguration('arboretum').get<string>('url', 'http://127.0.0.1:7317');
|
||||
}
|
||||
function readShowExternal(): boolean {
|
||||
return vscode.workspace.getConfiguration('arboretum').get<boolean>('showExternalSessions', false);
|
||||
}
|
||||
function readNotify(): boolean {
|
||||
return vscode.workspace.getConfiguration('arboretum').get<boolean>('notifyOnWaiting', true);
|
||||
}
|
||||
|
||||
async function setConnectedContext(connected: boolean): Promise<void> {
|
||||
await vscode.commands.executeCommand('setContext', 'arboretum.connected', connected);
|
||||
}
|
||||
|
||||
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
||||
const log = vscode.window.createOutputChannel('Arboretum');
|
||||
context.subscriptions.push(log);
|
||||
|
||||
const auth = new Auth(context.secrets);
|
||||
const store = new Store();
|
||||
store.showExternalSessions = readShowExternal();
|
||||
const rest = new RestClient({ baseUrl: readUrl() });
|
||||
|
||||
const reseed = async (): Promise<void> => {
|
||||
try {
|
||||
await store.seed(rest);
|
||||
} catch (err) {
|
||||
log.appendLine(`seed failed: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBar = new StatusBar(store);
|
||||
context.subscriptions.push(statusBar);
|
||||
|
||||
const ws = new ArbWsClient({
|
||||
socketFactory: wsSocketFactory,
|
||||
onSession: (e) => store.applySession(e),
|
||||
onWorktree: (e) => store.applyWorktree(e),
|
||||
onGroup: (e) => store.applyGroup(e),
|
||||
onReady: () => void reseed(),
|
||||
onStatus: (s) => {
|
||||
const connected = s === 'open';
|
||||
statusBar.setConnected(connected);
|
||||
void setConnectedContext(connected);
|
||||
},
|
||||
log: (m) => log.appendLine(m),
|
||||
});
|
||||
|
||||
const terminals = new SessionTerminalManager(ws);
|
||||
context.subscriptions.push({ dispose: () => terminals.dispose() });
|
||||
|
||||
const reposProvider = new ReposTreeProvider(store);
|
||||
const groupsProvider = new GroupsTreeProvider(store);
|
||||
const reposView = vscode.window.createTreeView('arboretum.repos', { treeDataProvider: reposProvider, showCollapseAll: true });
|
||||
const groupsView = vscode.window.createTreeView('arboretum.groups', { treeDataProvider: groupsProvider, showCollapseAll: true });
|
||||
context.subscriptions.push(reposView, groupsView);
|
||||
|
||||
const workspace = new WorkspaceMapper(store, reposView, reposProvider);
|
||||
context.subscriptions.push(workspace);
|
||||
|
||||
const signIn = async (): Promise<void> => {
|
||||
const token = await vscode.window.showInputBox({
|
||||
prompt: `Arboretum token for ${rest.url}`,
|
||||
password: true,
|
||||
ignoreFocusOut: true,
|
||||
placeHolder: 'arb_… (printed once on first daemon start, or created in Settings → Tokens)',
|
||||
});
|
||||
if (!token) return;
|
||||
// valide le token avant de le stocker (sonde isolée pour ne pas polluer le client principal).
|
||||
const probe = new RestClient({ baseUrl: rest.url, token });
|
||||
try {
|
||||
await probe.me();
|
||||
} catch (err) {
|
||||
void vscode.window.showErrorMessage(`Arboretum: sign in failed — ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
await auth.storeToken(token);
|
||||
rest.setToken(token);
|
||||
ws.configure(rest.url, token);
|
||||
ws.connect();
|
||||
void vscode.window.showInformationMessage('Arboretum: connected.');
|
||||
};
|
||||
|
||||
const signOut = async (): Promise<void> => {
|
||||
await auth.clearToken();
|
||||
rest.setToken(null);
|
||||
ws.disconnect();
|
||||
store.clear();
|
||||
await setConnectedContext(false);
|
||||
void vscode.window.showInformationMessage('Arboretum: signed out.');
|
||||
};
|
||||
|
||||
registerCommands(context, { store, rest, ws, terminals, workspace, signIn, signOut, reseed });
|
||||
|
||||
// Notifications : ouvrir le terminal ou répondre Yes/No (réutilise le helper transient-attach).
|
||||
const notifier = new WaitingNotifier(store, {
|
||||
notifyEnabled: () => readNotify(),
|
||||
openTerminal: (session) => terminals.open(session, 'interactive'),
|
||||
answer: (session, action) => {
|
||||
void answerSession({ store, rest, ws, terminals, workspace, signIn, signOut, reseed }, session, action).catch((err: Error) =>
|
||||
log.appendLine(`answer failed: ${err.message}`),
|
||||
);
|
||||
},
|
||||
});
|
||||
context.subscriptions.push(notifier);
|
||||
|
||||
// Réglages à chaud : URL (reconfigure REST+WS), sessions externes (refiltre), notif (lu à la volée).
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeConfiguration(async (e) => {
|
||||
if (e.affectsConfiguration('arboretum.url')) {
|
||||
rest.setBaseUrl(readUrl());
|
||||
const token = await auth.getToken();
|
||||
if (token) ws.configure(rest.url, token);
|
||||
}
|
||||
if (e.affectsConfiguration('arboretum.showExternalSessions')) {
|
||||
store.setShowExternalSessions(readShowExternal());
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Connexion initiale si un token est déjà mémorisé.
|
||||
await setConnectedContext(false);
|
||||
const token = await auth.getToken();
|
||||
if (token) {
|
||||
rest.setToken(token);
|
||||
ws.configure(rest.url, token);
|
||||
ws.connect();
|
||||
}
|
||||
|
||||
context.subscriptions.push({ dispose: () => ws.disconnect() });
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
// tout est enregistré dans context.subscriptions → disposé par VS Code.
|
||||
}
|
||||
57
packages/vscode/src/notifications.ts
Normal file
57
packages/vscode/src/notifications.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Notifications natives sur front montant d'une session vers `waiting` (doublonne utilement le Web
|
||||
// Push, ici dans l'éditeur). Suit l'activité précédente par session pour ne notifier qu'à la transition,
|
||||
// pas à chaque mise à jour. Actions : ouvrir le terminal, ou répondre Yes/No via la commande WS `answer`.
|
||||
import * as vscode from 'vscode';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import type { Store } from './state/store.js';
|
||||
|
||||
export interface NotificationHooks {
|
||||
notifyEnabled(): boolean;
|
||||
openTerminal(session: SessionSummary): void;
|
||||
answer(session: SessionSummary, action: 'confirm' | 'deny'): void;
|
||||
}
|
||||
|
||||
export class WaitingNotifier implements vscode.Disposable {
|
||||
/** dernière activité connue par session → détection du front montant vers 'waiting'. */
|
||||
private readonly lastActivity = new Map<string, string | null | undefined>();
|
||||
private readonly unsubscribe: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly store: Store,
|
||||
private readonly hooks: NotificationHooks,
|
||||
) {
|
||||
this.unsubscribe = store.onDidChange(() => this.check());
|
||||
}
|
||||
|
||||
private check(): void {
|
||||
const sessions = this.store.allSessions();
|
||||
const seen = new Set<string>();
|
||||
for (const s of sessions) {
|
||||
seen.add(s.id);
|
||||
const prev = this.lastActivity.get(s.id);
|
||||
const cur = s.live ? s.activity : null;
|
||||
this.lastActivity.set(s.id, cur);
|
||||
if (cur === 'waiting' && prev !== 'waiting' && prev !== undefined && this.hooks.notifyEnabled()) {
|
||||
this.notify(s);
|
||||
}
|
||||
// prev === undefined : première observation (au seed) → on n'annonce pas l'historique.
|
||||
}
|
||||
for (const id of [...this.lastActivity.keys()]) if (!seen.has(id)) this.lastActivity.delete(id);
|
||||
}
|
||||
|
||||
private notify(session: SessionSummary): void {
|
||||
const title = session.title?.trim() || session.command;
|
||||
const detail = session.waitingFor ? ` — ${session.waitingFor}` : '';
|
||||
void vscode.window
|
||||
.showInformationMessage(`Arboretum: "${title}" is waiting for input${detail}`, 'Open Terminal', 'Yes', 'No')
|
||||
.then((choice) => {
|
||||
if (choice === 'Open Terminal') this.hooks.openTerminal(session);
|
||||
else if (choice === 'Yes') this.hooks.answer(session, 'confirm');
|
||||
else if (choice === 'No') this.hooks.answer(session, 'deny');
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
196
packages/vscode/src/state/store.ts
Normal file
196
packages/vscode/src/state/store.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// État mémoire de l'extension — module pur (aucun import vscode), testable.
|
||||
// Seedé via REST au (re)connect, puis patché par les events WS. Émet onDidChange à chaque mutation ;
|
||||
// les arbres / status bar / notifications s'y abonnent. Principe « groupe léger » : on ne corrèle pas
|
||||
// les worktrees aux sessions via la liste embarquée (potentiellement périmée) mais par `cwd`, exactement
|
||||
// comme `sessionsForCwd` côté serveur.
|
||||
import type {
|
||||
GroupSummary,
|
||||
RepoSummary,
|
||||
SessionSummary,
|
||||
WorktreeSummary,
|
||||
} from '@arboretum/shared';
|
||||
import type { GroupEvent, SessionEvent, WorktreeEvent } from '../api/ws-client.js';
|
||||
import type { RestClient } from '../api/rest-client.js';
|
||||
|
||||
/** Normalise un chemin absolu pour la corrélation (retire le slash final). */
|
||||
function normPath(p: string): string {
|
||||
return p.replace(/\/+$/, '') || '/';
|
||||
}
|
||||
|
||||
export class Store {
|
||||
private readonly repos = new Map<string, RepoSummary>();
|
||||
/** worktrees par repo, indexés par chemin (clé stable). */
|
||||
private readonly worktrees = new Map<string, Map<string, WorktreeSummary>>();
|
||||
private readonly sessions = new Map<string, SessionSummary>();
|
||||
private readonly groups = new Map<string, GroupSummary>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
/** affiche aussi les sessions externes (découvertes hors Arboretum). */
|
||||
showExternalSessions = false;
|
||||
|
||||
onDidChange(cb: () => void): () => void {
|
||||
this.listeners.add(cb);
|
||||
return () => this.listeners.delete(cb);
|
||||
}
|
||||
|
||||
/** Bascule l'affichage des sessions externes et notifie les abonnés (refiltre les arbres). */
|
||||
setShowExternalSessions(value: boolean): void {
|
||||
if (this.showExternalSessions === value) return;
|
||||
this.showExternalSessions = value;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const cb of this.listeners) cb();
|
||||
}
|
||||
|
||||
/** Charge tout l'état depuis le daemon (au premier hello_ok et à chaque reconnexion). */
|
||||
async seed(rest: RestClient): Promise<void> {
|
||||
const [repos, worktrees, sessions, groups] = await Promise.all([
|
||||
rest.listRepos(),
|
||||
rest.listWorktrees(),
|
||||
rest.listSessions(false),
|
||||
rest.listGroups(),
|
||||
]);
|
||||
this.repos.clear();
|
||||
this.worktrees.clear();
|
||||
this.sessions.clear();
|
||||
this.groups.clear();
|
||||
for (const r of repos.repos) this.repos.set(r.id, r);
|
||||
for (const w of worktrees.worktrees) this.putWorktree(w);
|
||||
for (const s of sessions.sessions) this.sessions.set(s.id, s);
|
||||
for (const g of groups.groups) this.groups.set(g.id, g);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.repos.clear();
|
||||
this.worktrees.clear();
|
||||
this.sessions.clear();
|
||||
this.groups.clear();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private putWorktree(w: WorktreeSummary): void {
|
||||
let byPath = this.worktrees.get(w.repoId);
|
||||
if (!byPath) {
|
||||
byPath = new Map();
|
||||
this.worktrees.set(w.repoId, byPath);
|
||||
}
|
||||
byPath.set(normPath(w.path), w);
|
||||
// les sessions embarquées dans le worktree alimentent aussi la map sessions (état initial).
|
||||
for (const s of w.sessions) if (!this.sessions.has(s.id)) this.sessions.set(s.id, s);
|
||||
}
|
||||
|
||||
// ---- réducteurs d'events WS ----
|
||||
|
||||
applySession(e: SessionEvent): void {
|
||||
if (e.type === 'session_update') {
|
||||
this.sessions.set(e.session.id, e.session);
|
||||
} else {
|
||||
// session_exit : marque morte si connue (sinon ignore).
|
||||
const prev = this.sessions.get(e.sessionId);
|
||||
if (prev) this.sessions.set(e.sessionId, { ...prev, live: false, status: 'exited', exitCode: e.exitCode });
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
applyWorktree(e: WorktreeEvent): void {
|
||||
switch (e.type) {
|
||||
case 'repo_update':
|
||||
this.repos.set(e.repo.id, e.repo);
|
||||
break;
|
||||
case 'repo_removed':
|
||||
this.repos.delete(e.repoId);
|
||||
this.worktrees.delete(e.repoId);
|
||||
break;
|
||||
case 'worktree_update':
|
||||
this.putWorktree(e.worktree);
|
||||
break;
|
||||
case 'worktree_removed':
|
||||
this.worktrees.get(e.repoId)?.delete(normPath(e.path));
|
||||
break;
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
applyGroup(e: GroupEvent): void {
|
||||
if (e.type === 'group_update') this.groups.set(e.group.id, e.group);
|
||||
else this.groups.delete(e.groupId);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
// ---- lecture ----
|
||||
|
||||
listRepos(): RepoSummary[] {
|
||||
return [...this.repos.values()]
|
||||
.filter((r) => !r.hidden && r.valid)
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
getRepo(id: string): RepoSummary | undefined {
|
||||
return this.repos.get(id);
|
||||
}
|
||||
|
||||
listWorktrees(repoId: string): WorktreeSummary[] {
|
||||
const byPath = this.worktrees.get(repoId);
|
||||
if (!byPath) return [];
|
||||
// worktree principal en tête, puis tri par branche/chemin.
|
||||
return [...byPath.values()].sort((a, b) => {
|
||||
if (a.isMain !== b.isMain) return a.isMain ? -1 : 1;
|
||||
return (a.branch ?? a.path).localeCompare(b.branch ?? b.path);
|
||||
});
|
||||
}
|
||||
|
||||
getWorktree(repoId: string, path: string): WorktreeSummary | undefined {
|
||||
return this.worktrees.get(repoId)?.get(normPath(path));
|
||||
}
|
||||
|
||||
/** sessions corrélées à un worktree par cwd (comme le serveur) ; filtre les externes selon le réglage. */
|
||||
sessionsForWorktree(path: string): SessionSummary[] {
|
||||
const wp = normPath(path);
|
||||
return [...this.sessions.values()]
|
||||
.filter((s) => normPath(s.cwd) === wp)
|
||||
.filter((s) => this.showExternalSessions || s.source === 'managed')
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
getSession(id: string): SessionSummary | undefined {
|
||||
return this.sessions.get(id);
|
||||
}
|
||||
|
||||
listGroups(): GroupSummary[] {
|
||||
return [...this.groups.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
getGroup(id: string): GroupSummary | undefined {
|
||||
return this.groups.get(id);
|
||||
}
|
||||
|
||||
/** sessions de groupe (couvrant plusieurs repos) attachées à un groupe donné. */
|
||||
sessionsForGroup(groupId: string): SessionSummary[] {
|
||||
return [...this.sessions.values()]
|
||||
.filter((s) => s.groupId === groupId)
|
||||
.filter((s) => this.showExternalSessions || s.source === 'managed')
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
/** sessions en attente d'input (activity === 'waiting') — pour la status bar et les notifications. */
|
||||
waitingSessions(): SessionSummary[] {
|
||||
return [...this.sessions.values()].filter((s) => s.live && s.activity === 'waiting');
|
||||
}
|
||||
|
||||
allSessions(): SessionSummary[] {
|
||||
return [...this.sessions.values()];
|
||||
}
|
||||
|
||||
/** trouve le worktree dont le chemin == folderPath (mapping workspace). */
|
||||
findWorktreeByPath(folderPath: string): { repoId: string; worktree: WorktreeSummary } | undefined {
|
||||
const target = normPath(folderPath);
|
||||
for (const [repoId, byPath] of this.worktrees) {
|
||||
const wt = byPath.get(target);
|
||||
if (wt) return { repoId, worktree: wt };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
40
packages/vscode/src/status-bar.ts
Normal file
40
packages/vscode/src/status-bar.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Status bar : compteur des sessions Claude en attente d'input (`activity === 'waiting'`).
|
||||
// Clic → quick-pick des sessions en attente (commande arboretum.showWaiting). Masqué si déconnecté
|
||||
// ou s'il n'y a rien en attente.
|
||||
import * as vscode from 'vscode';
|
||||
import type { Store } from './state/store.js';
|
||||
|
||||
export class StatusBar implements vscode.Disposable {
|
||||
private readonly item: vscode.StatusBarItem;
|
||||
private connected = false;
|
||||
private readonly unsubscribe: () => void;
|
||||
|
||||
constructor(private readonly store: Store) {
|
||||
this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
||||
this.item.command = 'arboretum.showWaiting';
|
||||
this.unsubscribe = store.onDidChange(() => this.update());
|
||||
this.update();
|
||||
}
|
||||
|
||||
setConnected(connected: boolean): void {
|
||||
this.connected = connected;
|
||||
this.update();
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
const count = this.connected ? this.store.waitingSessions().length : 0;
|
||||
if (count === 0) {
|
||||
this.item.hide();
|
||||
return;
|
||||
}
|
||||
this.item.text = `$(bell-dot) ${count}`;
|
||||
this.item.tooltip = `Arboretum: ${count} session(s) waiting for input`;
|
||||
this.item.color = new vscode.ThemeColor('statusBarItem.warningForeground');
|
||||
this.item.show();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.unsubscribe();
|
||||
this.item.dispose();
|
||||
}
|
||||
}
|
||||
85
packages/vscode/src/terminal/session-bridge.ts
Normal file
85
packages/vscode/src/terminal/session-bridge.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Pont d'une session vers un terminal — module pur (aucun import vscode), testable.
|
||||
// Le ws-client remet des octets BRUTS (un chunk PTY peut couper un caractère UTF-8 en frontière de
|
||||
// frame, exactement comme côté web où xterm.write(Uint8Array) gérait le décodage). Ici la cible est
|
||||
// un Pseudoterminal qui veut des `string` → on décode en streaming avec un TextDecoder, et on recrée
|
||||
// le décodeur à chaque RESYNC pour ne pas traîner une séquence partielle au-delà d'un reset.
|
||||
import type { ArbWsClient, Attachment, AttachmentSink, DetachReason } from '../api/ws-client.js';
|
||||
|
||||
/** Décodeur UTF-8 incrémental : conserve l'octet partiel d'une frontière de frame jusqu'au suivant. */
|
||||
export class StreamingUtf8Decoder {
|
||||
private decoder = new TextDecoder('utf-8');
|
||||
|
||||
decode(chunk: Uint8Array): string {
|
||||
return this.decoder.decode(chunk, { stream: true });
|
||||
}
|
||||
|
||||
/** Vide l'état partiel et repart à neuf (appelé sur RESYNC). */
|
||||
reset(): void {
|
||||
this.decoder = new TextDecoder('utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionBridgeCallbacks {
|
||||
/** texte décodé à écrire dans le terminal. */
|
||||
onText(text: string): void;
|
||||
/** RESYNC reçu : le terminal doit se réinitialiser (le texte de replay suit via onText). */
|
||||
onReset(): void;
|
||||
onDetached(reason: DetachReason): void;
|
||||
onControlChanged(controlling: boolean): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relie une session Arboretum à un terminal via le ws-client. Instancié par l'adaptateur
|
||||
* Pseudoterminal ; reste pur pour être testable avec un faux ws-client.
|
||||
*/
|
||||
export class SessionBridge {
|
||||
private attachment: Attachment | null = null;
|
||||
private readonly decoder = new StreamingUtf8Decoder();
|
||||
private detached = false;
|
||||
|
||||
constructor(
|
||||
private readonly ws: ArbWsClient,
|
||||
private readonly sessionId: string,
|
||||
private readonly mode: 'interactive' | 'observer',
|
||||
private readonly cb: SessionBridgeCallbacks,
|
||||
) {}
|
||||
|
||||
/** Ouvre l'attache ; résout quand le canal est établi. Rejette si la session est introuvable. */
|
||||
async open(cols: number, rows: number): Promise<void> {
|
||||
const sink: AttachmentSink = {
|
||||
data: (payload) => {
|
||||
if (this.detached) return;
|
||||
const text = this.decoder.decode(payload);
|
||||
if (text.length > 0) this.cb.onText(text);
|
||||
},
|
||||
reset: () => {
|
||||
this.decoder.reset();
|
||||
this.cb.onReset();
|
||||
},
|
||||
detached: (reason) => {
|
||||
this.detached = true;
|
||||
this.cb.onDetached(reason);
|
||||
},
|
||||
controlChanged: (c) => this.cb.onControlChanged(c),
|
||||
};
|
||||
this.attachment = await this.ws.attach({ sessionId: this.sessionId, mode: this.mode, cols, rows, sink });
|
||||
}
|
||||
|
||||
sendStdin(data: string): void {
|
||||
this.attachment?.sendStdin(data);
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
this.attachment?.resize(cols, rows);
|
||||
}
|
||||
|
||||
answer(action: 'select' | 'confirm' | 'deny', optionN?: number): void {
|
||||
this.attachment?.answer(action, optionN);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.detached = true;
|
||||
this.attachment?.detach();
|
||||
this.attachment = null;
|
||||
}
|
||||
}
|
||||
86
packages/vscode/src/terminal/session-pty.ts
Normal file
86
packages/vscode/src/terminal/session-pty.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// Adaptateur vscode.Pseudoterminal : expose une session Arboretum comme un terminal NATIF de VS Code
|
||||
// (rendu, scrollback, copier/coller, liens gratuits). C'est la pièce maîtresse de l'intégration —
|
||||
// pas de xterm-in-webview. Le pont WS/décodage vit dans SessionBridge (pur) ; cet adaptateur ne fait
|
||||
// que relier les events Pseudoterminal ↔ bridge.
|
||||
import * as vscode from 'vscode';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import type { ArbWsClient } from '../api/ws-client.js';
|
||||
import { SessionBridge } from './session-bridge.js';
|
||||
|
||||
const ANSI_RESET = '\x1bc'; // RIS : réinitialisation complète du terminal (avant le replay d'un RESYNC)
|
||||
|
||||
export class SessionTerminalManager {
|
||||
private readonly terminals = new Map<string, vscode.Terminal>();
|
||||
private readonly bridges = new Map<string, SessionBridge>();
|
||||
|
||||
constructor(private readonly ws: ArbWsClient) {}
|
||||
|
||||
/** Bridge d'une session si un terminal est ouvert (pour répondre à un dialogue sans ré-attacher). */
|
||||
getBridge(sessionId: string): SessionBridge | undefined {
|
||||
return this.bridges.get(sessionId);
|
||||
}
|
||||
|
||||
/** Ouvre (ou ré-affiche) un terminal natif attaché à la session. */
|
||||
open(session: SessionSummary, mode: 'interactive' | 'observer'): void {
|
||||
const existing = this.terminals.get(session.id);
|
||||
if (existing) {
|
||||
existing.show();
|
||||
return;
|
||||
}
|
||||
|
||||
const writeEmitter = new vscode.EventEmitter<string>();
|
||||
const closeEmitter = new vscode.EventEmitter<number | void>();
|
||||
let bridge: SessionBridge | null = null;
|
||||
|
||||
const pty: vscode.Pseudoterminal = {
|
||||
onDidWrite: writeEmitter.event,
|
||||
onDidClose: closeEmitter.event,
|
||||
open: (dims) => {
|
||||
bridge = new SessionBridge(this.ws, session.id, mode, {
|
||||
onText: (text) => writeEmitter.fire(text),
|
||||
onReset: () => writeEmitter.fire(ANSI_RESET),
|
||||
onDetached: () => {
|
||||
writeEmitter.fire('\r\n\x1b[2m[arboretum: session ended]\x1b[0m\r\n');
|
||||
closeEmitter.fire();
|
||||
},
|
||||
onControlChanged: (controlling) => {
|
||||
if (!controlling && mode === 'interactive') {
|
||||
writeEmitter.fire('\r\n\x1b[33m[arboretum: control lost — now observing]\x1b[0m\r\n');
|
||||
}
|
||||
},
|
||||
});
|
||||
this.bridges.set(session.id, bridge);
|
||||
bridge.open(dims?.columns ?? 80, dims?.rows ?? 24).catch((err: Error) => {
|
||||
writeEmitter.fire(`\r\n\x1b[31m[arboretum: cannot attach — ${err.message}]\x1b[0m\r\n`);
|
||||
closeEmitter.fire();
|
||||
});
|
||||
},
|
||||
close: () => {
|
||||
bridge?.close();
|
||||
this.bridges.delete(session.id);
|
||||
this.terminals.delete(session.id);
|
||||
},
|
||||
handleInput: (data) => {
|
||||
if (mode === 'interactive') bridge?.sendStdin(data);
|
||||
},
|
||||
setDimensions: (dims) => bridge?.resize(dims.columns, dims.rows),
|
||||
};
|
||||
|
||||
const name = this.terminalName(session, mode);
|
||||
const terminal = vscode.window.createTerminal({ name, pty, iconPath: new vscode.ThemeIcon('comment-discussion') });
|
||||
this.terminals.set(session.id, terminal);
|
||||
terminal.show();
|
||||
}
|
||||
|
||||
private terminalName(session: SessionSummary, mode: 'interactive' | 'observer'): string {
|
||||
const base = session.title?.trim() || session.cwd.split('/').pop() || session.command;
|
||||
const prefix = mode === 'observer' ? '👁 ' : '';
|
||||
return `${prefix}${base}`;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const t of this.terminals.values()) t.dispose();
|
||||
this.terminals.clear();
|
||||
this.bridges.clear();
|
||||
}
|
||||
}
|
||||
64
packages/vscode/src/views/groups-tree.ts
Normal file
64
packages/vscode/src/views/groups-tree.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Arbre Groups → repos membres → worktrees/sessions, plus les sessions de groupe (multi-repo, --add-dir)
|
||||
// rattachées directement au groupe. Rafraîchi en temps réel (store.onDidChange).
|
||||
import * as vscode from 'vscode';
|
||||
import type { Store } from '../state/store.js';
|
||||
import type { GroupsNode } from './nodes.js';
|
||||
import { groupTreeItem, repoTreeItem, sessionTreeItem, worktreeTreeItem } from './tree-items.js';
|
||||
|
||||
export class GroupsTreeProvider implements vscode.TreeDataProvider<GroupsNode> {
|
||||
private readonly changeEmitter = new vscode.EventEmitter<GroupsNode | undefined>();
|
||||
readonly onDidChangeTreeData = this.changeEmitter.event;
|
||||
|
||||
constructor(private readonly store: Store) {
|
||||
store.onDidChange(() => this.changeEmitter.fire(undefined));
|
||||
}
|
||||
|
||||
getTreeItem(node: GroupsNode): vscode.TreeItem {
|
||||
switch (node.kind) {
|
||||
case 'group':
|
||||
return groupTreeItem(node.group);
|
||||
case 'repo':
|
||||
return repoTreeItem(node.repo);
|
||||
case 'worktree':
|
||||
return worktreeTreeItem(node.worktree);
|
||||
case 'group-session':
|
||||
case 'session': {
|
||||
const item = sessionTreeItem(node.session);
|
||||
if (node.session.live) {
|
||||
item.command = { command: 'arboretum.attachSession', title: 'Attach', arguments: [node] };
|
||||
}
|
||||
return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getChildren(node?: GroupsNode): GroupsNode[] {
|
||||
if (!node) {
|
||||
return this.store.listGroups().map((group) => ({ kind: 'group', group }));
|
||||
}
|
||||
if (node.kind === 'group') {
|
||||
const children: GroupsNode[] = [];
|
||||
// sessions de groupe (couvrant plusieurs repos) en tête
|
||||
for (const session of this.store.sessionsForGroup(node.group.id)) {
|
||||
children.push({ kind: 'group-session', session, groupId: node.group.id });
|
||||
}
|
||||
// puis les repos membres
|
||||
for (const repoId of node.group.repoIds) {
|
||||
const repo = this.store.getRepo(repoId);
|
||||
if (repo) children.push({ kind: 'repo', repo });
|
||||
}
|
||||
return children;
|
||||
}
|
||||
if (node.kind === 'repo') {
|
||||
return this.store
|
||||
.listWorktrees(node.repo.id)
|
||||
.map((worktree) => ({ kind: 'worktree', repoId: node.repo.id, worktree }));
|
||||
}
|
||||
if (node.kind === 'worktree') {
|
||||
return this.store
|
||||
.sessionsForWorktree(node.worktree.path)
|
||||
.map((session) => ({ kind: 'session', session, repoId: node.repoId, worktreePath: node.worktree.path }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
33
packages/vscode/src/views/nodes.ts
Normal file
33
packages/vscode/src/views/nodes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Types de nœuds des arbres. Les handlers de commandes reçoivent un nœud (clic ou menu contextuel) et
|
||||
// narrowent sur `kind`. Volontairement plats (données + discriminant) ; la hiérarchie parent/enfant est
|
||||
// tenue par les providers (maps indexées) pour que reveal()/getParent fonctionnent.
|
||||
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
|
||||
export interface RepoNode {
|
||||
kind: 'repo';
|
||||
repo: RepoSummary;
|
||||
}
|
||||
export interface WorktreeNode {
|
||||
kind: 'worktree';
|
||||
repoId: string;
|
||||
worktree: WorktreeSummary;
|
||||
}
|
||||
export interface SessionNode {
|
||||
kind: 'session';
|
||||
session: SessionSummary;
|
||||
repoId: string;
|
||||
worktreePath: string;
|
||||
}
|
||||
export interface GroupNode {
|
||||
kind: 'group';
|
||||
group: GroupSummary;
|
||||
}
|
||||
export interface GroupSessionNode {
|
||||
kind: 'group-session';
|
||||
session: SessionSummary;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export type ReposNode = RepoNode | WorktreeNode | SessionNode;
|
||||
export type GroupsNode = GroupNode | RepoNode | WorktreeNode | SessionNode | GroupSessionNode;
|
||||
export type AnyNode = ReposNode | GroupsNode;
|
||||
BIN
packages/vscode/src/views/repos-tree.ts
Normal file
BIN
packages/vscode/src/views/repos-tree.ts
Normal file
Binary file not shown.
96
packages/vscode/src/views/tree-items.ts
Normal file
96
packages/vscode/src/views/tree-items.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Helpers de construction des TreeItem (icônes d'état, labels, contextValue) partagés par les deux
|
||||
// arbres (Repositories & Groups). Les `contextValue` pilotent les menus contextuels (cf. package.json) ;
|
||||
// les regex y matchent des sous-chaînes → format stable `arboretum:session:<live|dead>[:discovered][:waiting]`.
|
||||
import * as vscode from 'vscode';
|
||||
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
|
||||
export function repoTreeItem(repo: RepoSummary): vscode.TreeItem {
|
||||
const item = new vscode.TreeItem(repo.label, vscode.TreeItemCollapsibleState.Expanded);
|
||||
item.id = `repo:${repo.id}`;
|
||||
item.contextValue = 'arboretum:repo';
|
||||
item.iconPath = new vscode.ThemeIcon('repo');
|
||||
item.description = repo.defaultBranch ?? '';
|
||||
item.tooltip = repo.path;
|
||||
return item;
|
||||
}
|
||||
|
||||
export function worktreeTreeItem(worktree: WorktreeSummary): vscode.TreeItem {
|
||||
const label = worktree.branch ?? (worktree.detached ? '(detached)' : worktree.head.slice(0, 8));
|
||||
const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Expanded);
|
||||
item.id = `wt:${worktree.path}`;
|
||||
item.contextValue = 'arboretum:worktree';
|
||||
item.iconPath = new vscode.ThemeIcon(worktree.isMain ? 'star-full' : 'git-branch');
|
||||
item.description = worktreeStatus(worktree);
|
||||
item.tooltip = worktree.path;
|
||||
item.resourceUri = vscode.Uri.file(worktree.path);
|
||||
return item;
|
||||
}
|
||||
|
||||
function worktreeStatus(w: WorktreeSummary): string {
|
||||
const parts: string[] = [];
|
||||
if (w.isMain) parts.push('main');
|
||||
if (w.git.ahead > 0) parts.push(`↑${w.git.ahead}`);
|
||||
if (w.git.behind > 0) parts.push(`↓${w.git.behind}`);
|
||||
if (w.git.dirtyCount > 0) parts.push(`●${w.git.dirtyCount}`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function sessionContextValue(s: SessionSummary): string {
|
||||
let v = `arboretum:session:${s.live ? 'live' : 'dead'}`;
|
||||
if (s.source === 'discovered') v += ':discovered';
|
||||
if (s.live && s.activity === 'waiting') v += ':waiting';
|
||||
return v;
|
||||
}
|
||||
|
||||
export function sessionTreeItem(s: SessionSummary): vscode.TreeItem {
|
||||
const label = s.title?.trim() || s.command;
|
||||
const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None);
|
||||
item.id = `session:${s.id}`;
|
||||
item.contextValue = sessionContextValue(s);
|
||||
item.iconPath = sessionIcon(s);
|
||||
item.description = sessionDescription(s);
|
||||
item.tooltip = sessionTooltip(s);
|
||||
return item;
|
||||
}
|
||||
|
||||
function sessionIcon(s: SessionSummary): vscode.ThemeIcon {
|
||||
if (!s.live) return new vscode.ThemeIcon('circle-slash');
|
||||
switch (s.activity) {
|
||||
case 'waiting':
|
||||
return new vscode.ThemeIcon('bell-dot', new vscode.ThemeColor('charts.yellow'));
|
||||
case 'busy':
|
||||
return new vscode.ThemeIcon('loading~spin');
|
||||
case 'idle':
|
||||
return new vscode.ThemeIcon('pass', new vscode.ThemeColor('charts.green'));
|
||||
default:
|
||||
return new vscode.ThemeIcon('terminal');
|
||||
}
|
||||
}
|
||||
|
||||
function sessionDescription(s: SessionSummary): string {
|
||||
const parts: string[] = [];
|
||||
// Affiche 'available' pour idle (cohérent avec le libellé web) ; l'enum reste 'idle' côté protocole.
|
||||
if (s.live && s.activity) parts.push(s.activity === 'idle' ? 'available' : s.activity);
|
||||
else if (!s.live) parts.push('exited');
|
||||
if (s.source === 'discovered') parts.push('external');
|
||||
if (s.groupId) parts.push('group');
|
||||
if (s.waitingFor) parts.push(`— ${s.waitingFor}`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function sessionTooltip(s: SessionSummary): string {
|
||||
const lines = [`${s.command} — ${s.cwd}`, `status: ${s.status}${s.live ? ' (live)' : ''}`];
|
||||
if (s.activity) lines.push(`activity: ${s.activity}`);
|
||||
if (s.addedDirs && s.addedDirs.length > 0) lines.push(`added dirs: ${s.addedDirs.length}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function groupTreeItem(group: GroupSummary): vscode.TreeItem {
|
||||
const item = new vscode.TreeItem(group.label, vscode.TreeItemCollapsibleState.Collapsed);
|
||||
item.id = `group:${group.id}`;
|
||||
item.contextValue = 'arboretum:group';
|
||||
item.iconPath = new vscode.ThemeIcon('folder-library');
|
||||
item.description = `${group.repoIds.length} repos`;
|
||||
if (group.description) item.tooltip = group.description;
|
||||
return item;
|
||||
}
|
||||
57
packages/vscode/src/workspace.ts
Normal file
57
packages/vscode/src/workspace.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Conscience du workspace (Phase D) : mappe les dossiers ouverts dans VS Code aux repos/worktrees
|
||||
// Arboretum par égalité de chemin, met en évidence le worktree correspondant dans l'arbre, et fournit
|
||||
// le repoId courant aux commandes contextuelles (« start session here », « create worktree here »).
|
||||
import * as vscode from 'vscode';
|
||||
import type { Store } from './state/store.js';
|
||||
import type { ReposNode } from './views/nodes.js';
|
||||
import type { ReposTreeProvider } from './views/repos-tree.js';
|
||||
|
||||
function normPath(p: string): string {
|
||||
return p.replace(/\/+$/, '') || '/';
|
||||
}
|
||||
|
||||
export class WorkspaceMapper implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly store: Store,
|
||||
private readonly treeView: vscode.TreeView<ReposNode>,
|
||||
private readonly provider: ReposTreeProvider,
|
||||
) {
|
||||
this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(() => this.revealCurrent()));
|
||||
// re-tente après chaque rafraîchissement de l'état (les worktrees arrivent après la connexion).
|
||||
this.disposables.push({ dispose: store.onDidChange(() => this.revealCurrent()) });
|
||||
}
|
||||
|
||||
/** repoId correspondant à un dossier ouvert (via worktree ou racine de repo), pour les actions contextuelles. */
|
||||
currentRepoId(): string | undefined {
|
||||
for (const folder of vscode.workspace.workspaceFolders ?? []) {
|
||||
const p = normPath(folder.uri.fsPath);
|
||||
const match = this.store.findWorktreeByPath(p);
|
||||
if (match) return match.repoId;
|
||||
const repo = this.store.listRepos().find((r) => normPath(r.path) === p);
|
||||
if (repo) return repo.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Met en évidence dans l'arbre le worktree du premier dossier ouvert qui matche. */
|
||||
revealCurrent(): void {
|
||||
if (!this.treeView.visible) return;
|
||||
for (const folder of vscode.workspace.workspaceFolders ?? []) {
|
||||
const match = this.store.findWorktreeByPath(normPath(folder.uri.fsPath));
|
||||
if (!match) continue;
|
||||
const node = this.provider.worktreeNode(match.repoId, match.worktree.path);
|
||||
if (node) {
|
||||
void this.treeView.reveal(node, { select: false, focus: false, expand: true }).then(undefined, () => {
|
||||
/* reveal est best-effort : un échec (nœud pas encore matérialisé) n'est pas bloquant */
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
15
packages/vscode/test/config.test.ts
Normal file
15
packages/vscode/test/config.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeBaseUrl, wsUrlFromBase } from '../src/config.js';
|
||||
|
||||
describe('config', () => {
|
||||
it('normalise la base URL (slash final, défaut)', () => {
|
||||
expect(normalizeBaseUrl('http://127.0.0.1:7317/')).toBe('http://127.0.0.1:7317');
|
||||
expect(normalizeBaseUrl(' http://host:8080// ')).toBe('http://host:8080');
|
||||
expect(normalizeBaseUrl('')).toBe('http://127.0.0.1:7317');
|
||||
});
|
||||
|
||||
it('dérive l’URL WebSocket (/ws, http→ws, https→wss)', () => {
|
||||
expect(wsUrlFromBase('http://127.0.0.1:7317')).toBe('ws://127.0.0.1:7317/ws');
|
||||
expect(wsUrlFromBase('https://box.ts.net')).toBe('wss://box.ts.net/ws');
|
||||
});
|
||||
});
|
||||
51
packages/vscode/test/rest-client.test.ts
Normal file
51
packages/vscode/test/rest-client.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RestClient, RestError } from '../src/api/rest-client.js';
|
||||
|
||||
type Captured = { url: string; init: RequestInit };
|
||||
|
||||
function fakeFetch(response: { ok: boolean; status: number; body: unknown }, captured: Captured[]) {
|
||||
return vi.fn(async (url: string, init: RequestInit) => {
|
||||
captured.push({ url, init });
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
json: async () => response.body,
|
||||
} as unknown as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('RestClient', () => {
|
||||
it('appelle /api/v1/auth/me avec le header Bearer', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const rest = new RestClient(
|
||||
{ baseUrl: 'http://127.0.0.1:7317/', token: 'arb_secret' },
|
||||
fakeFetch({ ok: true, status: 200, body: { ok: true, tokenId: 't', tokenLabel: 'l', serverVersion: '1.8.0' } }, captured),
|
||||
);
|
||||
const me = await rest.me();
|
||||
expect(me.serverVersion).toBe('1.8.0');
|
||||
expect(captured[0]?.url).toBe('http://127.0.0.1:7317/api/v1/auth/me');
|
||||
const headers = captured[0]?.init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe('Bearer arb_secret');
|
||||
});
|
||||
|
||||
it('normalise les erreurs serveur en RestError (code + status)', async () => {
|
||||
const rest = new RestClient(
|
||||
{ baseUrl: 'http://127.0.0.1:7317', token: 'bad' },
|
||||
fakeFetch({ ok: false, status: 401, body: { error: { code: 'BAD_TOKEN', message: 'invalid token' } } }, []),
|
||||
);
|
||||
await expect(rest.me()).rejects.toMatchObject({ code: 'BAD_TOKEN', status: 401 });
|
||||
await expect(rest.me()).rejects.toBeInstanceOf(RestError);
|
||||
});
|
||||
|
||||
it('encode les ids dans les chemins et envoie le corps JSON', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const rest = new RestClient(
|
||||
{ baseUrl: 'http://h:1', token: 't' },
|
||||
fakeFetch({ ok: true, status: 200, body: { worktree: {} } }, captured),
|
||||
);
|
||||
await rest.commitWorktree('repo id', { path: '/w', message: 'msg' });
|
||||
expect(captured[0]?.url).toBe('http://h:1/api/v1/repos/repo%20id/worktrees/commit');
|
||||
expect(captured[0]?.init.method).toBe('POST');
|
||||
expect(JSON.parse(String(captured[0]?.init.body))).toEqual({ path: '/w', message: 'msg' });
|
||||
});
|
||||
});
|
||||
24
packages/vscode/test/session-bridge.test.ts
Normal file
24
packages/vscode/test/session-bridge.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { StreamingUtf8Decoder } from '../src/terminal/session-bridge.js';
|
||||
|
||||
describe('StreamingUtf8Decoder', () => {
|
||||
it('recompose un caractère UTF-8 coupé en frontière de frame', () => {
|
||||
const dec = new StreamingUtf8Decoder();
|
||||
// 'é' = 0xC3 0xA9, coupé en deux frames
|
||||
expect(dec.decode(new Uint8Array([0xc3]))).toBe('');
|
||||
expect(dec.decode(new Uint8Array([0xa9]))).toBe('é');
|
||||
});
|
||||
|
||||
it('décode l’ASCII normalement', () => {
|
||||
const dec = new StreamingUtf8Decoder();
|
||||
expect(dec.decode(new Uint8Array([0x6c, 0x73, 0x0a]))).toBe('ls\n');
|
||||
});
|
||||
|
||||
it('reset() vide l’état partiel', () => {
|
||||
const dec = new StreamingUtf8Decoder();
|
||||
expect(dec.decode(new Uint8Array([0xe2, 0x9c]))).toBe(''); // début d’un ✓ (3 octets)
|
||||
dec.reset();
|
||||
// après reset, l’octet partiel est oublié → un nouveau caractère complet décode proprement
|
||||
expect(dec.decode(new Uint8Array([0x41]))).toBe('A');
|
||||
});
|
||||
});
|
||||
103
packages/vscode/test/store.test.ts
Normal file
103
packages/vscode/test/store.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
import { Store } from '../src/state/store.js';
|
||||
|
||||
function repo(id: string): RepoSummary {
|
||||
return {
|
||||
id,
|
||||
path: `/code/${id}`,
|
||||
label: id,
|
||||
defaultBranch: 'main',
|
||||
postCreateHooks: [],
|
||||
preTrust: false,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
valid: true,
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
function worktree(repoId: string, path: string, branch: string): WorktreeSummary {
|
||||
return {
|
||||
repoId,
|
||||
path,
|
||||
branch,
|
||||
head: 'abcdef0',
|
||||
detached: false,
|
||||
locked: false,
|
||||
prunable: false,
|
||||
isMain: branch === 'main',
|
||||
git: { ahead: 0, behind: 0, dirtyCount: 0, upstream: null },
|
||||
sessions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function session(id: string, cwd: string, over: Partial<SessionSummary> = {}): SessionSummary {
|
||||
return {
|
||||
id,
|
||||
cwd,
|
||||
command: 'claude',
|
||||
title: null,
|
||||
status: 'running',
|
||||
live: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
clients: 0,
|
||||
source: 'managed',
|
||||
claudeSessionId: null,
|
||||
pid: 123,
|
||||
resumable: false,
|
||||
attachable: true,
|
||||
registryStatus: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Store', () => {
|
||||
it('corrèle les sessions au worktree par cwd et filtre les externes', () => {
|
||||
const store = new Store();
|
||||
store.applyWorktree({ type: 'repo_update', repo: repo('app') });
|
||||
store.applyWorktree({ type: 'worktree_update', repoId: 'app', worktree: worktree('app', '/code/app', 'main') });
|
||||
|
||||
store.applySession({ type: 'session_update', session: session('s1', '/code/app') });
|
||||
store.applySession({ type: 'session_update', session: session('s2', '/code/app', { source: 'discovered' }) });
|
||||
|
||||
// externes masquées par défaut
|
||||
expect(store.sessionsForWorktree('/code/app').map((s) => s.id)).toEqual(['s1']);
|
||||
store.setShowExternalSessions(true);
|
||||
expect(store.sessionsForWorktree('/code/app').map((s) => s.id).sort()).toEqual(['s1', 's2']);
|
||||
});
|
||||
|
||||
it('liste les repos visibles, triés', () => {
|
||||
const store = new Store();
|
||||
store.applyWorktree({ type: 'repo_update', repo: { ...repo('b'), label: 'beta' } });
|
||||
store.applyWorktree({ type: 'repo_update', repo: { ...repo('a'), label: 'alpha' } });
|
||||
store.applyWorktree({ type: 'repo_update', repo: { ...repo('h'), label: 'hidden', hidden: true } });
|
||||
expect(store.listRepos().map((r) => r.label)).toEqual(['alpha', 'beta']);
|
||||
});
|
||||
|
||||
it('session_exit marque la session morte', () => {
|
||||
const store = new Store();
|
||||
store.applySession({ type: 'session_update', session: session('s1', '/x') });
|
||||
store.applySession({ type: 'session_exit', sessionId: 's1', exitCode: 0, signal: null });
|
||||
expect(store.getSession('s1')?.live).toBe(false);
|
||||
expect(store.getSession('s1')?.status).toBe('exited');
|
||||
});
|
||||
|
||||
it('waitingSessions ne retient que les sessions live en attente', () => {
|
||||
const store = new Store();
|
||||
store.applySession({ type: 'session_update', session: session('s1', '/x', { activity: 'waiting' }) });
|
||||
store.applySession({ type: 'session_update', session: session('s2', '/y', { activity: 'busy' }) });
|
||||
store.applySession({ type: 'session_update', session: session('s3', '/z', { activity: 'waiting', live: false, status: 'exited' }) });
|
||||
expect(store.waitingSessions().map((s) => s.id)).toEqual(['s1']);
|
||||
});
|
||||
|
||||
it('worktree_removed retire le worktree', () => {
|
||||
const store = new Store();
|
||||
store.applyWorktree({ type: 'repo_update', repo: repo('app') });
|
||||
store.applyWorktree({ type: 'worktree_update', repoId: 'app', worktree: worktree('app', '/code/app-wt', 'feat') });
|
||||
expect(store.listWorktrees('app')).toHaveLength(1);
|
||||
store.applyWorktree({ type: 'worktree_removed', repoId: 'app', path: '/code/app-wt' });
|
||||
expect(store.listWorktrees('app')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
136
packages/vscode/test/ws-client.test.ts
Normal file
136
packages/vscode/test/ws-client.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BINARY_FRAME, FLOW, encodeBinaryFrame, type ClientMessage } from '@arboretum/shared';
|
||||
import { ArbWsClient, type AttachmentSink, type RawSocket } from '../src/api/ws-client.js';
|
||||
|
||||
class FakeSocket implements RawSocket {
|
||||
sent: string[] = [];
|
||||
private openCb: (() => void) | null = null;
|
||||
private textCb: ((t: string) => void) | null = null;
|
||||
private binaryCb: ((d: Uint8Array) => void) | null = null;
|
||||
private closeCb: (() => void) | null = null;
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close(): void {
|
||||
this.closeCb?.();
|
||||
}
|
||||
onOpen(cb: () => void): void {
|
||||
this.openCb = cb;
|
||||
}
|
||||
onText(cb: (t: string) => void): void {
|
||||
this.textCb = cb;
|
||||
}
|
||||
onBinary(cb: (d: Uint8Array) => void): void {
|
||||
this.binaryCb = cb;
|
||||
}
|
||||
onClose(cb: () => void): void {
|
||||
this.closeCb = cb;
|
||||
}
|
||||
onError(): void {
|
||||
/* non utilisé dans ces tests */
|
||||
}
|
||||
|
||||
fireOpen(): void {
|
||||
this.openCb?.();
|
||||
}
|
||||
fireText(msg: unknown): void {
|
||||
this.textCb?.(JSON.stringify(msg));
|
||||
}
|
||||
fireBinary(data: Uint8Array): void {
|
||||
this.binaryCb?.(data);
|
||||
}
|
||||
|
||||
/** messages de contrôle envoyés, parsés et filtrés par type. */
|
||||
controls<T extends ClientMessage['type']>(type: T): Array<Extract<ClientMessage, { type: T }>> {
|
||||
return this.sent
|
||||
.map((s) => JSON.parse(s) as ClientMessage)
|
||||
.filter((m): m is Extract<ClientMessage, { type: T }> => m.type === type);
|
||||
}
|
||||
}
|
||||
|
||||
function makeNoopSink(): AttachmentSink {
|
||||
return { data: () => {}, reset: () => {}, detached: () => {}, controlChanged: () => {} };
|
||||
}
|
||||
|
||||
describe('ArbWsClient', () => {
|
||||
let fake: FakeSocket;
|
||||
let client: ArbWsClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fake = new FakeSocket();
|
||||
client = new ArbWsClient({ socketFactory: () => fake });
|
||||
client.configure('http://127.0.0.1:7317', 'arb_token');
|
||||
client.connect();
|
||||
fake.fireOpen();
|
||||
fake.fireText({ type: 'hello_ok', protocol: 1, serverVersion: '1.8.0' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('envoie hello puis sub après hello_ok', () => {
|
||||
expect(fake.controls('hello')).toHaveLength(1);
|
||||
expect(fake.controls('sub')[0]?.topics).toEqual(['sessions', 'worktrees', 'groups']);
|
||||
});
|
||||
|
||||
it('corrèle attach→attached et délivre les OUTPUT au sink', async () => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const sink: AttachmentSink = { ...makeNoopSink(), data: (p) => chunks.push(p) };
|
||||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink });
|
||||
fake.fireText({ type: 'attached', channel: 1, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||||
const att = await p;
|
||||
expect(att.channel).toBe(1);
|
||||
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 1, new Uint8Array([104, 105]))); // "hi"
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect([...(chunks[0] ?? [])]).toEqual([104, 105]);
|
||||
});
|
||||
|
||||
it('ACK au seuil ACK_EVERY_BYTES', async () => {
|
||||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
|
||||
fake.fireText({ type: 'attached', channel: 7, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||||
await p;
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
|
||||
const acks = fake.controls('ack');
|
||||
expect(acks).toHaveLength(1);
|
||||
expect(acks[0]).toMatchObject({ channel: 7, bytes: FLOW.ACK_EVERY_BYTES });
|
||||
});
|
||||
|
||||
it('ACK traînant flushe un reliquat sous le seuil après 200ms', async () => {
|
||||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
|
||||
fake.fireText({ type: 'attached', channel: 2, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||||
await p;
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 2, new Uint8Array(1000)));
|
||||
expect(fake.controls('ack')).toHaveLength(0); // sous le seuil → pas d’ACK immédiat
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(fake.controls('ack')).toEqual([{ type: 'ack', channel: 2, bytes: 1000 }]);
|
||||
});
|
||||
|
||||
it('RESYNC réinitialise le terminal et les compteurs d’ACK', async () => {
|
||||
let resets = 0;
|
||||
const sink: AttachmentSink = { ...makeNoopSink(), reset: () => (resets += 1) };
|
||||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink });
|
||||
fake.fireText({ type: 'attached', channel: 3, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||||
await p;
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 3, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.RESYNC, 3, new Uint8Array([65]))); // reset + replay "A"
|
||||
expect(resets).toBe(1);
|
||||
// après resync, le replay ne compte pas : un nouvel OUTPUT au seuil ré-ACK à partir de 0
|
||||
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 3, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
|
||||
const acks = fake.controls('ack');
|
||||
expect(acks[acks.length - 1]).toMatchObject({ bytes: FLOW.ACK_EVERY_BYTES });
|
||||
});
|
||||
|
||||
it('envoie stdin/resize sur le bon canal', async () => {
|
||||
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
|
||||
fake.fireText({ type: 'attached', channel: 5, sessionId: 'sess', mode: 'interactive', controlling: true });
|
||||
const att = await p;
|
||||
att.sendStdin('ls\n');
|
||||
att.resize(120, 40);
|
||||
expect(fake.controls('stdin')[0]).toEqual({ type: 'stdin', channel: 5, data: 'ls\n' });
|
||||
expect(fake.controls('resize')[0]).toEqual({ type: 'resize', channel: 5, cols: 120, rows: 40 });
|
||||
});
|
||||
});
|
||||
16
packages/vscode/tsconfig.json
Normal file
16
packages/vscode/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"verbatimModuleSyntax": false,
|
||||
"composite": false,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"noEmit": true,
|
||||
"types": ["node"],
|
||||
"lib": ["ES2023"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
119
packages/web/src/components/NewProjectModal.vue
Normal file
119
packages/web/src/components/NewProjectModal.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<header class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-zinc-100">{{ t('project.title') }}</h2>
|
||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||
</header>
|
||||
<p class="text-xs text-zinc-500">{{ t('project.intro') }}</p>
|
||||
|
||||
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('project.nameLabel') }}
|
||||
<input v-model="name" class="input font-mono" :placeholder="t('project.namePlaceholder')" required :disabled="creating" />
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('project.rootLabel') }}
|
||||
<input v-model="root" class="input font-mono" :placeholder="t('project.rootPlaceholder')" required :disabled="creating" />
|
||||
</label>
|
||||
<div>
|
||||
<button type="button" class="btn text-xs" :disabled="creating" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
|
||||
</div>
|
||||
<DirectoryPicker v-if="showPicker" mode="dir" :initial-path="root" @select="onPickRoot" @close="showPicker = false" />
|
||||
|
||||
<label class="flex items-center gap-2 text-xs text-zinc-300">
|
||||
<input v-model="gitInit" type="checkbox" :disabled="creating" /> {{ t('project.gitInit') }}
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('project.commandLabel') }}
|
||||
<select v-model="command" class="input" :disabled="creating">
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<p class="font-mono text-[11px] text-zinc-500">{{ previewPath }}</p>
|
||||
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="submit" class="btn-primary" :disabled="creating || name.trim() === '' || root.trim() === ''">
|
||||
{{ creating ? t('project.creating') : t('project.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { useToastsStore } from '../stores/toasts';
|
||||
import DirectoryPicker from './DirectoryPicker.vue';
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const sessions = useSessionsStore();
|
||||
const settings = useSettingsStore();
|
||||
const toasts = useToastsStore();
|
||||
|
||||
const name = ref('');
|
||||
// Racine par défaut : la 1ère racine de scan (réglage Découverte) ; modifiable via le picker.
|
||||
const root = ref('');
|
||||
const gitInit = ref(false);
|
||||
const command = ref<'claude' | 'bash'>('claude');
|
||||
const showPicker = ref(false);
|
||||
const creating = ref(false);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
|
||||
// Aperçu du chemin final (indicatif ; jointure naïve suffisante pour l'affichage).
|
||||
const previewPath = computed(() => {
|
||||
const r = root.value.trim().replace(/\/+$/, '');
|
||||
const n = name.value.trim();
|
||||
return r && n ? `${r}/${n}` : '';
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (!settings.loaded) {
|
||||
try {
|
||||
await settings.fetch();
|
||||
} catch {
|
||||
/* le préremplissage de la racine est facultatif */
|
||||
}
|
||||
}
|
||||
root.value = settings.scanRoots[0] ?? '';
|
||||
});
|
||||
|
||||
function onPickRoot(path: string): void {
|
||||
root.value = path;
|
||||
showPicker.value = false;
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (creating.value || name.value.trim() === '' || root.value.trim() === '') return;
|
||||
creating.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
const res = await sessions.createProject({
|
||||
root: root.value.trim(),
|
||||
name: name.value.trim(),
|
||||
gitInit: gitInit.value,
|
||||
command: command.value,
|
||||
});
|
||||
toasts.success(t('toast.projectCreated'));
|
||||
emit('close');
|
||||
await router.push({ name: 'session', params: { id: res.session.id } });
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
82
packages/web/src/components/SessionContextBar.vue
Normal file
82
packages/web/src/components/SessionContextBar.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<!-- Barre de contexte sous l'en-tête : groupe + repos couverts (avec branche). Masquée s'il n'y a
|
||||
rien d'utile à montrer (session simple dans un dossier non reconnu → le cwd du header suffit). -->
|
||||
<div v-if="shouldShow" class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-zinc-800 bg-zinc-900/40 px-3 py-1.5 text-xs">
|
||||
<RouterLink
|
||||
v-if="group"
|
||||
:to="{ name: 'group', params: { id: group.id } }"
|
||||
class="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-zinc-200 transition-colors hover:bg-zinc-800"
|
||||
:title="t('terminal.group')"
|
||||
>
|
||||
<span class="h-2.5 w-2.5 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
|
||||
<span class="font-medium">{{ group.label }}</span>
|
||||
<span class="text-zinc-500">→</span>
|
||||
</RouterLink>
|
||||
|
||||
<span v-if="group" class="text-zinc-600">{{ t('terminal.covers') }}</span>
|
||||
|
||||
<span
|
||||
v-for="e in entries"
|
||||
:key="e.dir"
|
||||
class="flex items-center gap-1.5 rounded bg-zinc-950/60 px-1.5 py-0.5"
|
||||
:title="e.dir"
|
||||
>
|
||||
<span class="font-mono text-zinc-300">{{ e.label }}</span>
|
||||
<span v-if="e.branch" class="badge bg-zinc-800 font-mono text-zinc-400">{{ e.branch }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useGroupsStore } from '../stores/groups';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
|
||||
const props = defineProps<{ session: SessionSummary }>();
|
||||
const { t } = useI18n();
|
||||
const groups = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
|
||||
const group = computed(() => (props.session.groupId ? (groups.byId(props.session.groupId) ?? null) : null));
|
||||
|
||||
function basename(dir: string): string {
|
||||
return dir.split('/').filter(Boolean).pop() ?? dir;
|
||||
}
|
||||
|
||||
interface CoverEntry {
|
||||
dir: string;
|
||||
label: string;
|
||||
branch: string | null;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
// Résout un répertoire couvert vers repo + branche : worktree exact d'abord (branche connue),
|
||||
// sinon checkout principal d'un repo, sinon dernier segment du chemin (non résolu).
|
||||
function resolve(dir: string): CoverEntry {
|
||||
const wt = worktrees.worktrees.find((w) => w.path === dir);
|
||||
if (wt) {
|
||||
const repo = worktrees.repos.find((r) => r.id === wt.repoId);
|
||||
return { dir, label: repo?.label ?? basename(dir), branch: wt.branch, resolved: true };
|
||||
}
|
||||
const repo = worktrees.repos.find((r) => r.path === dir);
|
||||
if (repo) return { dir, label: repo.label, branch: null, resolved: true };
|
||||
return { dir, label: basename(dir), branch: null, resolved: false };
|
||||
}
|
||||
|
||||
const entries = computed<CoverEntry[]>(() => {
|
||||
const dirs = [props.session.cwd, ...(props.session.addedDirs ?? [])];
|
||||
const seen = new Set<string>();
|
||||
const out: CoverEntry[] = [];
|
||||
for (const d of dirs) {
|
||||
if (seen.has(d)) continue;
|
||||
seen.add(d);
|
||||
out.push(resolve(d));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Afficher si on est dans un groupe, ou si au moins un répertoire correspond à un repo connu.
|
||||
const shouldShow = computed(() => group.value !== null || entries.value.some((e) => e.resolved));
|
||||
</script>
|
||||
@@ -12,6 +12,14 @@
|
||||
{{ t('groups.spanBadge', spannedCount) }}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
||||
<button
|
||||
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
|
||||
:aria-label="t('terminal.openFullscreen')"
|
||||
:title="t('terminal.openFullscreen')"
|
||||
@click="emit('open-fullscreen')"
|
||||
>
|
||||
<Expand class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
|
||||
:aria-label="maximized ? t('terminal.restore') : t('terminal.maximize')"
|
||||
@@ -31,7 +39,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Maximize2, Minimize2 } from '@lucide/vue';
|
||||
import { Expand, Maximize2, Minimize2 } from '@lucide/vue';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import SessionStateBadge from './SessionStateBadge.vue';
|
||||
@@ -39,7 +47,7 @@ import DialogPrompt from './DialogPrompt.vue';
|
||||
import TerminalView from './TerminalView.vue';
|
||||
|
||||
const props = defineProps<{ session: SessionSummary; maximized?: boolean }>();
|
||||
const emit = defineEmits<{ 'toggle-maximize': [] }>();
|
||||
const emit = defineEmits<{ 'toggle-maximize': []; 'open-fullscreen': [] }>();
|
||||
const worktrees = useWorktreesStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
Plancher min-h-80 pour les fenêtres courtes. -->
|
||||
<!-- une cellule maximisée → plein cadre ; sinon la grille responsive -->
|
||||
<div v-if="activeMax" class="grid grid-cols-1">
|
||||
<TerminalCell :session="activeMax" maximized class="h-[calc(100dvh-8rem)] min-h-80" @toggle-maximize="toggleMax(activeMax.id)" />
|
||||
<TerminalCell
|
||||
:session="activeMax"
|
||||
maximized
|
||||
class="h-[calc(100dvh-8rem)] min-h-80"
|
||||
@toggle-maximize="toggleMax(activeMax.id)"
|
||||
@open-fullscreen="openFullscreen(activeMax.id)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
<TerminalCell
|
||||
@@ -20,6 +26,7 @@
|
||||
:session="s"
|
||||
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
|
||||
@toggle-maximize="toggleMax(s.id)"
|
||||
@open-fullscreen="openFullscreen(s.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -29,15 +36,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import TerminalCell from './TerminalCell.vue';
|
||||
|
||||
const props = defineProps<{ sessions: SessionSummary[] }>();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const MAX_CELLS = 6;
|
||||
const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
|
||||
|
||||
// ouvrir le terminal choisi en plein écran (page dédiée /sessions/:id), comme depuis la liste des sessions.
|
||||
function openFullscreen(id: string): void {
|
||||
void router.push({ name: 'session', params: { id } });
|
||||
}
|
||||
|
||||
// Maximisation d'une cellule au choix (état purement présentation, local à la grille). Si la session
|
||||
// maximisée disparaît (terminée), on retombe automatiquement sur la grille.
|
||||
const maximizedId = ref<string | null>(null);
|
||||
|
||||
@@ -38,6 +38,8 @@ const ended = ref(false);
|
||||
let term: Terminal | null = null;
|
||||
let attachment: Attachment | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let intersectionObserver: IntersectionObserver | null = null;
|
||||
let onVisible: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -47,7 +49,7 @@ onMounted(async () => {
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: "ui-monospace, 'JetBrains Mono', 'Fira Code', Menlo, Consolas, monospace",
|
||||
scrollback: 10_000,
|
||||
scrollback: 20_000, // absorbe le replay élargi (REPLAY_TAIL_BYTES = 1 Mo) → on remonte plus loin
|
||||
theme: {
|
||||
background: '#09090b',
|
||||
foreground: '#d4d4d8',
|
||||
@@ -58,18 +60,24 @@ onMounted(async () => {
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(container.value);
|
||||
const activeTerm = term;
|
||||
|
||||
try {
|
||||
const { WebglAddon } = await import('@xterm/addon-webgl');
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => webgl.dispose());
|
||||
// perte de contexte GPU : on dispose (retour au renderer DOM) PUIS on repeint — sinon l'écran
|
||||
// reste figé sur le dernier rendu WebGL jusqu'à la prochaine interaction (« texte qui ne s'affiche
|
||||
// que si ça bouge »).
|
||||
webgl.onContextLoss(() => {
|
||||
webgl.dispose();
|
||||
activeTerm.refresh(0, activeTerm.rows - 1);
|
||||
});
|
||||
term.loadAddon(webgl);
|
||||
} catch {
|
||||
// WebGL indisponible : xterm retombe sur le renderer DOM
|
||||
}
|
||||
|
||||
fit.fit();
|
||||
const activeTerm = term;
|
||||
|
||||
// Re-mesure après stabilisation du layout flex : au `onMounted`, le conteneur peut ne pas avoir sa
|
||||
// taille finale (la hauteur flex se résout après le 1er paint) → un seul `fit()` donne trop peu de
|
||||
@@ -82,6 +90,9 @@ onMounted(async () => {
|
||||
/* conteneur sans dimension (démontage en cours) : ignoré */
|
||||
}
|
||||
attachment?.resize(activeTerm.cols, activeTerm.rows);
|
||||
// repaint explicite : le renderer WebGL ne repeint pas toujours une zone non « dirty » (retour
|
||||
// d'arrière-plan, resync) → on force un rafraîchissement pour éviter un écran figé.
|
||||
activeTerm.refresh(0, activeTerm.rows - 1);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -120,12 +131,34 @@ onMounted(async () => {
|
||||
activeTerm.onData((data) => attachment?.sendStdin(data));
|
||||
resizeObserver = new ResizeObserver(refit);
|
||||
resizeObserver.observe(container.value);
|
||||
// Re-révélation RÉELLE du conteneur : cellule de grille démontée/remontée (maximize↔grille), scroll
|
||||
// hors/dans le viewport, ou navigation SPA — autant de cas que `visibilitychange` (onglet only) ne
|
||||
// couvre PAS. Au retour visible, on repeint (le rendu était gelé hors-écran) ET on flushe l'ACK pour
|
||||
// sortir d'une éventuelle pause serveur (le callback de xterm.write était throttlé tant que caché).
|
||||
intersectionObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
// évite un fit() sur conteneur de taille 0 (remontage transitoire) → déforme le TUI
|
||||
if (entry.intersectionRect.width === 0 || entry.intersectionRect.height === 0) continue;
|
||||
refit();
|
||||
attachment?.flushAck();
|
||||
}
|
||||
});
|
||||
intersectionObserver.observe(container.value);
|
||||
// au retour en avant-plan, forcer un repaint (le renderer WebGL peut avoir « gelé » l'écran pendant
|
||||
// que l'onglet était caché) ; le ws-client flushe en parallèle les ACK pour relancer le flux.
|
||||
onVisible = (): void => {
|
||||
if (document.visibilityState === 'visible') activeTerm.refresh(0, activeTerm.rows - 1);
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
if (props.mode === 'interactive') activeTerm.focus();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
resizeObserver?.disconnect();
|
||||
intersectionObserver?.disconnect();
|
||||
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
||||
attachment?.detach();
|
||||
term?.dispose();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<RouterLink
|
||||
v-for="s in worktree.sessions"
|
||||
v-for="s in visibleSessions"
|
||||
:key="s.id"
|
||||
:to="{ name: 'session', params: { id: s.id } }"
|
||||
class="flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5 transition-colors hover:bg-zinc-800"
|
||||
@@ -24,7 +24,16 @@
|
||||
<SessionStateBadge :session="liveSession(s)" />
|
||||
<span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span>
|
||||
</RouterLink>
|
||||
<span v-if="worktree.sessions.length === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span>
|
||||
<!-- sessions externes (lancées hors Arboretum) masquées par défaut : chip de révélation -->
|
||||
<button
|
||||
v-if="!store.showExternalSessions && externalCount > 0"
|
||||
type="button"
|
||||
class="rounded bg-zinc-800/40 px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:text-zinc-300"
|
||||
@click="store.showExternalSessions = true"
|
||||
>
|
||||
+{{ externalCount }} {{ t('worktrees.externalSessions') }}
|
||||
</button>
|
||||
<span v-if="visibleSessions.length === 0 && externalCount === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span>
|
||||
|
||||
<!-- lancement d'une session sur ce worktree ; carte principale = bosser sur la branche principale -->
|
||||
<button v-if="hasLiveSession && !showLauncher" class="btn text-xs" @click="showLauncher = true">+ {{ t('worktrees.start') }}</button>
|
||||
@@ -123,6 +132,17 @@ function liveSession(s: SessionSummary): SessionSummary {
|
||||
return sessions.sessions.find((x) => x.id === s.id) ?? s;
|
||||
}
|
||||
|
||||
// sessions « liées à l'app » (managées) affichées par défaut ; les externes (découvertes hors
|
||||
// Arboretum) ne le sont que si l'interrupteur global est activé → évite les listes à rallonge.
|
||||
const visibleSessions = computed(() =>
|
||||
store.showExternalSessions
|
||||
? props.worktree.sessions
|
||||
: props.worktree.sessions.filter((s) => liveSession(s).source === 'managed'),
|
||||
);
|
||||
const externalCount = computed(
|
||||
() => props.worktree.sessions.filter((s) => liveSession(s).source !== 'managed').length,
|
||||
);
|
||||
|
||||
// sessions corrélées actuellement bloquées sur un dialogue → réponse rapide inline.
|
||||
const waitingSessions = computed(() =>
|
||||
props.worktree.sessions.map(liveSession).filter((s) => s.live && s.activity === 'waiting' && s.dialog),
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
style="padding-bottom: calc(1rem + env(safe-area-inset-bottom))"
|
||||
>
|
||||
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-zinc-700" />
|
||||
<!-- accès recherche/palette : sur mobile la sidebar (et ⌘K) est indisponible → seul point d'entrée -->
|
||||
<button
|
||||
type="button"
|
||||
class="mb-3 flex w-full items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
@click="openSearch"
|
||||
>
|
||||
<Search :size="16" />
|
||||
<span>{{ t('common.search') }}</span>
|
||||
</button>
|
||||
<nav class="mb-3 flex flex-col gap-1">
|
||||
<NavItem
|
||||
v-for="item in secondary"
|
||||
@@ -31,12 +40,20 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Search } from '@lucide/vue';
|
||||
import { useNav } from '../../composables/useNav';
|
||||
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||
import NavItem from './NavItem.vue';
|
||||
import AppShellFooter from './AppShellFooter.vue';
|
||||
import BaseButton from '../ui/BaseButton.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { secondary, isActive } = useNav();
|
||||
const palette = useCommandPalette();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
function openSearch(): void {
|
||||
emit('close');
|
||||
palette.show();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, effectScope, ref } from 'vue';
|
||||
import type { WorktreeSummary } from '@arboretum/shared';
|
||||
import type { ListToolbarControls, SortDir } from './useListControls';
|
||||
import { useDebouncedRef } from './useDebouncedRef';
|
||||
@@ -29,7 +29,14 @@ function create(): WorktreeView {
|
||||
const filterDefList = worktreeFilters();
|
||||
const filterDefs = computed(() => filterDefList);
|
||||
|
||||
const accessor = (w: WorktreeSummary): string => `${w.branch ?? ''} ${w.path}`.toLowerCase();
|
||||
// label de repo cherchable (en plus de la branche et du chemin) → la recherche couvre le nom du dépôt.
|
||||
const repoLabelById = computed(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const r of useWorktreesStore().repos) m.set(r.id, r.label);
|
||||
return m;
|
||||
});
|
||||
const accessor = (w: WorktreeSummary): string =>
|
||||
`${repoLabelById.value.get(w.repoId) ?? ''} ${w.branch ?? ''} ${w.path}`.toLowerCase();
|
||||
|
||||
function apply(list: readonly WorktreeSummary[]): WorktreeSummary[] {
|
||||
let items = [...list];
|
||||
@@ -98,6 +105,12 @@ function create(): WorktreeView {
|
||||
}
|
||||
|
||||
export function useWorktreeView(): WorktreeView {
|
||||
instance ??= create();
|
||||
// Effets (watch du debounce, computeds) créés dans un scope DÉTACHÉ : sinon ils seraient rattachés
|
||||
// au scope du premier composant qui appelle ce singleton et stoppés à son démontage (navigation),
|
||||
// gelant le ref débouncé → la recherche ne filtrerait plus après un aller-retour de route.
|
||||
if (!instance) {
|
||||
const scope = effectScope(true);
|
||||
instance = scope.run(() => create())!;
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export default {
|
||||
},
|
||||
registryStatus: {
|
||||
busy: 'busy',
|
||||
idle: 'idle',
|
||||
idle: 'available',
|
||||
waiting: 'waiting',
|
||||
},
|
||||
dialog: {
|
||||
@@ -181,6 +181,21 @@ export default {
|
||||
clean: 'clean',
|
||||
dirty: '{n} changed',
|
||||
noSession: 'no session',
|
||||
externalSessions: 'external',
|
||||
showExternalSessions: 'Show external sessions ({n})',
|
||||
},
|
||||
project: {
|
||||
new: 'New project',
|
||||
title: 'New project',
|
||||
intro: 'Creates a new folder under the chosen root, then starts a session in it.',
|
||||
nameLabel: 'Project name',
|
||||
namePlaceholder: 'my-project',
|
||||
rootLabel: 'Root folder',
|
||||
rootPlaceholder: '/absolute/path/root',
|
||||
gitInit: 'Initialize a git repository (git init)',
|
||||
commandLabel: 'Command',
|
||||
create: 'Create',
|
||||
creating: 'Creating…',
|
||||
},
|
||||
terminal: {
|
||||
observer: 'observer (read-only)',
|
||||
@@ -191,6 +206,9 @@ export default {
|
||||
exitFullscreen: 'Exit fullscreen',
|
||||
maximize: 'Maximize',
|
||||
restore: 'Restore',
|
||||
openFullscreen: 'Open fullscreen',
|
||||
group: 'Group',
|
||||
covers: 'covers',
|
||||
},
|
||||
push: {
|
||||
enable: 'Enable notifications',
|
||||
@@ -265,7 +283,7 @@ export default {
|
||||
live: 'Live',
|
||||
waiting: 'Waiting',
|
||||
busy: 'Busy',
|
||||
idle: 'Idle',
|
||||
idle: 'Available',
|
||||
exited: 'Exited',
|
||||
resumable: 'Resumable',
|
||||
},
|
||||
@@ -334,6 +352,7 @@ export default {
|
||||
groupDeleted: 'Group deleted',
|
||||
groupSessionLaunched: 'Group session started',
|
||||
groupSessionPartial: 'Group session started ({n} repo(s) skipped)',
|
||||
projectCreated: 'Project created',
|
||||
genericError: 'Something went wrong',
|
||||
dismiss: 'Dismiss',
|
||||
},
|
||||
@@ -394,6 +413,20 @@ export default {
|
||||
removeRoot: 'Remove',
|
||||
scanInterval: 'Re-scan interval (minutes)',
|
||||
scanIntervalHint: '0 disables periodic scanning. Changes apply on the next daemon restart.',
|
||||
// Claude CLI
|
||||
claudeCli: 'Claude CLI',
|
||||
claudeCliHint: 'Where Arboretum finds the `claude` binary and its data. Handy when the daemon runs as a service with a minimal PATH that does not include ~/.local/bin.',
|
||||
claudeBinStatusOk: 'Detected',
|
||||
claudeBinStatusNotFound: 'Not found — sessions cannot start until this is resolved.',
|
||||
claudeBinStatusBadPath: 'The configured path is not an executable file.',
|
||||
claudeBinSourceConfigured: 'configured',
|
||||
claudeBinSourcePath: 'PATH',
|
||||
claudeBinPathLabel: 'Binary path override',
|
||||
claudeBinPathHint: 'Absolute path to the `claude` executable. Leave empty to auto-detect via PATH. Takes effect on the next session.',
|
||||
claudeBinPathPlaceholder: 'e.g. /home/you/.local/bin/claude',
|
||||
claudeHomeLabel: 'Claude home directory',
|
||||
claudeHomeHint: 'Override the ~/.claude location (transcripts & session registry). Applies on the next daemon restart.',
|
||||
claudeHomePlaceholder: 'e.g. /home/you/.claude',
|
||||
// Serveur (lecture seule)
|
||||
server: 'Server',
|
||||
serverHint: 'Set at startup via CLI flags — changing them requires restarting the daemon.',
|
||||
|
||||
@@ -58,7 +58,7 @@ const fr: typeof en = {
|
||||
},
|
||||
registryStatus: {
|
||||
busy: 'occupée',
|
||||
idle: 'inactive',
|
||||
idle: 'disponible',
|
||||
waiting: 'en attente',
|
||||
},
|
||||
dialog: {
|
||||
@@ -183,6 +183,21 @@ const fr: typeof en = {
|
||||
clean: 'propre',
|
||||
dirty: '{n} modifié(s)',
|
||||
noSession: 'aucune session',
|
||||
externalSessions: 'externes',
|
||||
showExternalSessions: 'Afficher les sessions externes ({n})',
|
||||
},
|
||||
project: {
|
||||
new: 'Nouveau projet',
|
||||
title: 'Nouveau projet',
|
||||
intro: 'Crée un nouveau dossier sous la racine choisie, puis y lance une session.',
|
||||
nameLabel: 'Nom du projet',
|
||||
namePlaceholder: 'mon-projet',
|
||||
rootLabel: 'Dossier racine',
|
||||
rootPlaceholder: '/chemin/absolu/racine',
|
||||
gitInit: 'Initialiser un dépôt git (git init)',
|
||||
commandLabel: 'Commande',
|
||||
create: 'Créer',
|
||||
creating: 'Création…',
|
||||
},
|
||||
terminal: {
|
||||
observer: 'observateur (lecture seule)',
|
||||
@@ -194,6 +209,9 @@ const fr: typeof en = {
|
||||
exitFullscreen: 'Quitter le plein écran',
|
||||
maximize: 'Agrandir',
|
||||
restore: 'Réduire',
|
||||
openFullscreen: 'Ouvrir en plein écran',
|
||||
group: 'Groupe',
|
||||
covers: 'couvre',
|
||||
},
|
||||
push: {
|
||||
enable: 'Activer les notifications',
|
||||
@@ -268,7 +286,7 @@ const fr: typeof en = {
|
||||
live: 'Active',
|
||||
waiting: 'En attente',
|
||||
busy: 'Occupée',
|
||||
idle: 'Au repos',
|
||||
idle: 'Disponible',
|
||||
exited: 'Terminée',
|
||||
resumable: 'Reprenable',
|
||||
},
|
||||
@@ -337,6 +355,7 @@ const fr: typeof en = {
|
||||
groupDeleted: 'Groupe supprimé',
|
||||
groupSessionLaunched: 'Session de groupe démarrée',
|
||||
groupSessionPartial: 'Session de groupe démarrée ({n} dépôt(s) ignoré(s))',
|
||||
projectCreated: 'Projet créé',
|
||||
genericError: 'Une erreur est survenue',
|
||||
dismiss: 'Fermer',
|
||||
},
|
||||
@@ -397,6 +416,20 @@ const fr: typeof en = {
|
||||
removeRoot: 'Retirer',
|
||||
scanInterval: 'Intervalle de re-scan (minutes)',
|
||||
scanIntervalHint: '0 désactive le scan périodique. Les changements prennent effet au prochain redémarrage du daemon.',
|
||||
// Claude CLI
|
||||
claudeCli: 'CLI Claude',
|
||||
claudeCliHint: 'Où Arboretum trouve le binaire `claude` et ses données. Utile quand le daemon tourne en service avec un PATH minimal qui n’inclut pas ~/.local/bin.',
|
||||
claudeBinStatusOk: 'Détecté',
|
||||
claudeBinStatusNotFound: 'Introuvable — impossible de lancer une session tant que ce n’est pas résolu.',
|
||||
claudeBinStatusBadPath: 'Le chemin configuré n’est pas un fichier exécutable.',
|
||||
claudeBinSourceConfigured: 'configuré',
|
||||
claudeBinSourcePath: 'PATH',
|
||||
claudeBinPathLabel: 'Chemin du binaire (override)',
|
||||
claudeBinPathHint: 'Chemin absolu vers l’exécutable `claude`. Laisser vide pour l’auto-détection via le PATH. Prend effet à la prochaine session.',
|
||||
claudeBinPathPlaceholder: 'ex. /home/vous/.local/bin/claude',
|
||||
claudeHomeLabel: 'Répertoire ~/.claude',
|
||||
claudeHomeHint: 'Surcharge l’emplacement de ~/.claude (transcripts & registre des sessions). Prend effet au redémarrage du daemon.',
|
||||
claudeHomePlaceholder: 'ex. /home/vous/.claude',
|
||||
// Serveur (lecture seule)
|
||||
server: 'Serveur',
|
||||
serverHint: 'Définis au démarrage via des flags CLI — les modifier nécessite de redémarrer le daemon.',
|
||||
|
||||
@@ -100,6 +100,12 @@ export class Attachment {
|
||||
this.pending = null;
|
||||
this.client.releaseAttachment(this);
|
||||
}
|
||||
|
||||
/** Flush ACK ciblé : relance immédiatement le flux si le serveur avait mis ce PTY en pause
|
||||
* (ex. retour-visible de la cellule, dont le rendu était gelé hors-écran). N'ACK que du traité. */
|
||||
flushAck(): void {
|
||||
this.client.flushAck(this);
|
||||
}
|
||||
}
|
||||
|
||||
export class WsClient {
|
||||
@@ -108,6 +114,7 @@ export class WsClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private ready = false;
|
||||
private stopped = true;
|
||||
private visibilityBound = false;
|
||||
private backoffMs = BACKOFF_MIN_MS;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** terminaux ouverts, ré-attachés à chaque reconnexion (ordre d'insertion = ordre FIFO) */
|
||||
@@ -121,6 +128,14 @@ export class WsClient {
|
||||
|
||||
connect(): void {
|
||||
this.stopped = false;
|
||||
// Au retour en avant-plan, le rendu se débloque : on flushe les ACK pour ne pas attendre le
|
||||
// débounce si le serveur s'était mis en pause pendant que l'onglet/PWA était caché.
|
||||
if (!this.visibilityBound && typeof document !== 'undefined') {
|
||||
this.visibilityBound = true;
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') this.flushAcks();
|
||||
});
|
||||
}
|
||||
if (this.socket || this.reconnectTimer) return;
|
||||
if (this.status.value === 'idle') this.status.value = 'connecting';
|
||||
this.openSocket();
|
||||
@@ -301,6 +316,9 @@ export class WsClient {
|
||||
att.lastAckBytes = 0;
|
||||
att.sink.reset();
|
||||
if (frame.payload.byteLength > 0) att.sink.write(frame.payload);
|
||||
// filet anti-stall : le replay ne passe PAS de callback de write → on arme quand même l'ACK
|
||||
// traînant pour que le flux suivant ne puisse pas rester gelé (serveur en pause).
|
||||
this.scheduleTrailingAck(att, att.epoch);
|
||||
return;
|
||||
}
|
||||
if (frame.type !== BINARY_FRAME.OUTPUT) return;
|
||||
@@ -315,18 +333,53 @@ export class WsClient {
|
||||
att.lastAckBytes = att.processedBytes;
|
||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||
}
|
||||
// ACK traînant débouncé : si le flux s'arrête avec un reliquat non-ACKé,
|
||||
// le serveur ne doit jamais rester en pause faute d'ACK suivant.
|
||||
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
|
||||
att.trailingAckTimer = setTimeout(() => {
|
||||
att.trailingAckTimer = null;
|
||||
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
|
||||
if (att.processedBytes > att.lastAckBytes) {
|
||||
att.lastAckBytes = att.processedBytes;
|
||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||
}
|
||||
}, 200);
|
||||
this.scheduleTrailingAck(att, epoch);
|
||||
});
|
||||
// Filet anti-stall, INDÉPENDANT du callback de xterm.write : ce callback est throttlé quand
|
||||
// l'onglet/PWA passe en arrière-plan (rAF + setTimeout ralentis) → sans cela l'ACK ne partait
|
||||
// plus, le serveur dépassait HIGH_WATERMARK et mettait le PTY en pause, et la sortie « chargeait
|
||||
// par à-coups » jusqu'à ce que l'utilisateur bouge. On arme donc l'ACK traînant dès la réception.
|
||||
this.scheduleTrailingAck(att, epoch);
|
||||
}
|
||||
|
||||
/**
|
||||
* (Ré)arme l'ACK traînant débouncé : si rien d'autre n'arrive, il envoie le reliquat d'octets
|
||||
* réellement traités par xterm (`processedBytes`) pour que le serveur sorte toujours de pause.
|
||||
* Appelé à la fois dans le callback de write ET à la réception d'une frame (le callback peut être
|
||||
* indéfiniment retardé quand le rendu est gelé). Sémantique inchangée : on n'ACK que du traité.
|
||||
*/
|
||||
private scheduleTrailingAck(att: Attachment, epoch: number): void {
|
||||
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
|
||||
att.trailingAckTimer = setTimeout(() => {
|
||||
att.trailingAckTimer = null;
|
||||
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
|
||||
if (att.processedBytes > att.lastAckBytes) {
|
||||
att.lastAckBytes = att.processedBytes;
|
||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** ACK immédiat du reliquat traité pour tous les terminaux ouverts — au retour en avant-plan,
|
||||
* pour rattraper sans attendre le débounce un serveur éventuellement en pause. */
|
||||
private flushAcks(): void {
|
||||
for (const att of this.attachments) this.flushAttachmentAck(att);
|
||||
}
|
||||
|
||||
/** ACK immédiat du reliquat traité d'UN attachment — n'ACK que du réellement traité (invariant
|
||||
* préservé). Factorisé : utilisé par flushAcks() et par le flush ciblé flushAck(). */
|
||||
private flushAttachmentAck(att: Attachment): void {
|
||||
if (att.closed || att.channel < 0) return;
|
||||
if (att.processedBytes > att.lastAckBytes) {
|
||||
att.lastAckBytes = att.processedBytes;
|
||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush ACK ciblé d'un seul attachment : relance un PTY mis en pause par le serveur quand la
|
||||
* cellule était cachée (cf. Attachment.flushAck, appelé au retour-visible). */
|
||||
flushAck(att: Attachment): void {
|
||||
this.flushAttachmentAck(att);
|
||||
}
|
||||
|
||||
private handleServerMessage(msg: ServerMessage): void {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared';
|
||||
import type { CreateProjectRequest, CreateProjectResponse, CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared';
|
||||
import { api } from '../lib/api';
|
||||
import { wsClient, type SessionEvent } from '../lib/ws-client';
|
||||
|
||||
@@ -106,6 +106,13 @@ export const useSessionsStore = defineStore('sessions', () => {
|
||||
return res.session;
|
||||
}
|
||||
|
||||
// Crée un nouveau dossier de projet `<root>/<name>` côté serveur puis y lance une session.
|
||||
async function createProject(req: CreateProjectRequest): Promise<CreateProjectResponse> {
|
||||
const res = await api.post<CreateProjectResponse>('/api/v1/projects', req);
|
||||
upsert(res.session);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function killSession(id: string): Promise<void> {
|
||||
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`);
|
||||
}
|
||||
@@ -134,6 +141,7 @@ export const useSessionsStore = defineStore('sessions', () => {
|
||||
startRealtime,
|
||||
stopRealtime,
|
||||
createSession,
|
||||
createProject,
|
||||
killSession,
|
||||
resumeSession,
|
||||
forkSession,
|
||||
|
||||
@@ -9,6 +9,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const server = ref<ServerInfo | null>(null);
|
||||
const scanRoots = ref<string[]>([]);
|
||||
const scanIntervalMin = ref(0);
|
||||
const claudeBinPath = ref<string | null>(null);
|
||||
const claudeHome = ref<string | null>(null);
|
||||
const loaded = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
@@ -16,6 +18,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
server.value = res.server;
|
||||
scanRoots.value = res.settings.scanRoots;
|
||||
scanIntervalMin.value = res.settings.scanIntervalMin;
|
||||
claudeBinPath.value = res.settings.claudeBinPath;
|
||||
claudeHome.value = res.settings.claudeHome;
|
||||
loaded.value = true;
|
||||
}
|
||||
|
||||
@@ -32,5 +36,5 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { server, scanRoots, scanIntervalMin, loaded, saving, fetch, save };
|
||||
return { server, scanRoots, scanIntervalMin, claudeBinPath, claudeHome, loaded, saving, fetch, save };
|
||||
});
|
||||
|
||||
@@ -23,6 +23,9 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
||||
const worktrees = ref<WorktreeSummary[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
// Préférence d'affichage (purement client) : révéler les sessions externes (lancées hors Arboretum)
|
||||
// dans les fiches worktree. Par défaut masquées pour éviter les listes à rallonge.
|
||||
const showExternalSessions = ref(false);
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function upsertRepo(repo: RepoSummary): void {
|
||||
@@ -168,6 +171,7 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
||||
worktrees,
|
||||
loading,
|
||||
loadError,
|
||||
showExternalSessions,
|
||||
worktreesForRepo,
|
||||
fetchAll,
|
||||
addRepo,
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
<label v-if="hiddenCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
|
||||
<input v-model="showHidden" type="checkbox" /> {{ t('repos.showHidden', { n: hiddenCount }) }}
|
||||
</label>
|
||||
<label v-if="externalSessionCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
|
||||
<input v-model="store.showExternalSessions" type="checkbox" />
|
||||
{{ t('worktrees.showExternalSessions', { n: externalSessionCount }) }}
|
||||
</label>
|
||||
<EmptyState v-if="visibleRepos.length === 0" :icon="Search" :title="t('controls.results', 0)" />
|
||||
<RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" />
|
||||
<Pagination
|
||||
@@ -82,6 +86,10 @@ const scanning = ref(false);
|
||||
const showHidden = ref(false);
|
||||
|
||||
const hiddenCount = computed(() => store.repos.filter((r) => r.hidden).length);
|
||||
// nombre de sessions externes (lancées hors Arboretum) embarquées dans les worktrees → pilote la case.
|
||||
const externalSessionCount = computed(
|
||||
() => store.worktrees.reduce((n, w) => n + w.sessions.filter((s) => s.source !== 'managed').length, 0),
|
||||
);
|
||||
|
||||
// repos visibles : les masqués sont exclus sauf si « afficher les masqués » est coché. Triés par libellé ;
|
||||
// puis masqués quand un filtre/recherche worktree actif ne laisse rien dans le repo.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<component :is="isFullscreen ? Minimize : Maximize" class="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
<SessionContextBar v-if="session" :session="session" />
|
||||
<DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" />
|
||||
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
||||
<!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). -->
|
||||
@@ -33,12 +34,17 @@ import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Maximize, Minimize } from '@lucide/vue';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import { useGroupsStore } from '../stores/groups';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import TerminalView from '../components/TerminalView.vue';
|
||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||
import SessionContextBar from '../components/SessionContextBar.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useSessionsStore();
|
||||
const groups = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
|
||||
const sessionId = computed(() => String(route.params.id));
|
||||
const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive'));
|
||||
@@ -61,6 +67,9 @@ function toggleFullscreen(): void {
|
||||
onMounted(() => {
|
||||
// navigation directe : garantit l'en-tête (cwd/commande) même si l'AppShell n'a pas encore chargé.
|
||||
if (store.sessions.length === 0) void store.fetchSessions();
|
||||
// best-effort : la barre de contexte a besoin des groupes + worktrees pour résoudre repos/branches.
|
||||
if (groups.groups.length === 0) void groups.fetchGroups();
|
||||
if (worktrees.repos.length === 0) void worktrees.fetchAll();
|
||||
document.addEventListener('fullscreenchange', syncFullscreen);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<PageHeader :title="t('nav.sessions')">
|
||||
<BaseButton variant="primary" size="sm" :icon="FolderPlus" @click="showNewProject = true">
|
||||
{{ t('project.new') }}
|
||||
</BaseButton>
|
||||
<BaseButton v-if="hasDiscovered" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingAll" @click="onHideDiscovered">
|
||||
{{ t('sessions.hideDiscovered') }}
|
||||
</BaseButton>
|
||||
@@ -37,6 +40,7 @@
|
||||
@select="onPickCwd"
|
||||
@close="showPicker = false"
|
||||
/>
|
||||
<NewProjectModal v-if="showNewProject" @close="showNewProject = false" />
|
||||
|
||||
<SkeletonRow v-if="store.loading && store.sessions.length === 0" />
|
||||
<EmptyState v-else-if="store.sessions.length === 0" :icon="TerminalSquare" :title="t('sessions.empty')" />
|
||||
@@ -102,7 +106,7 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { RefreshCw, FolderOpen, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search } from '@lucide/vue';
|
||||
import { RefreshCw, FolderOpen, FolderPlus, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search } from '@lucide/vue';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useToastsStore } from '../stores/toasts';
|
||||
@@ -118,6 +122,7 @@ import Pagination from '../components/Pagination.vue';
|
||||
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
||||
import NewProjectModal from '../components/NewProjectModal.vue';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -136,6 +141,7 @@ const controls = useListControls<SessionSummary>({
|
||||
const newCwd = ref('');
|
||||
const newCommand = ref<'claude' | 'bash'>('claude');
|
||||
const showPicker = ref(false);
|
||||
const showNewProject = ref(false);
|
||||
const creating = ref(false);
|
||||
const killArmedId = ref<string | null>(null);
|
||||
const killing = ref(false);
|
||||
|
||||
@@ -137,6 +137,44 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Claude CLI -->
|
||||
<section class="card flex flex-col gap-3">
|
||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||
<Terminal :size="16" /> {{ t('settings.claudeCli') }}
|
||||
</h2>
|
||||
<p class="text-xs text-zinc-500">{{ t('settings.claudeCliHint') }}</p>
|
||||
|
||||
<!-- Diagnostic de détection -->
|
||||
<div v-if="settings.server" class="card-inset flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<component :is="claudeBin.ok ? CheckCircle2 : AlertCircle" :size="15" :class="claudeBin.ok ? 'text-emerald-400' : 'text-rose-400'" />
|
||||
<span :class="claudeBin.ok ? 'text-zinc-300' : 'text-rose-300'">
|
||||
{{ claudeBin.ok ? t('settings.claudeBinStatusOk') : (claudeBin.source === 'configured' ? t('settings.claudeBinStatusBadPath') : t('settings.claudeBinStatusNotFound')) }}
|
||||
</span>
|
||||
<code v-if="claudeBin.path" class="min-w-0 truncate font-mono text-zinc-200" :title="claudeBin.path">{{ claudeBin.path }}</code>
|
||||
<span v-if="claudeBin.source" class="badge bg-zinc-700/40 text-zinc-300">
|
||||
{{ claudeBin.source === 'configured' ? t('settings.claudeBinSourceConfigured') : t('settings.claudeBinSourcePath') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-zinc-400">{{ t('settings.claudeBinPathLabel') }}</span>
|
||||
<input v-model.trim="claudeBinDraft" class="input font-mono" :placeholder="t('settings.claudeBinPathPlaceholder')" />
|
||||
<span class="text-xs text-zinc-500">{{ t('settings.claudeBinPathHint') }}</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-zinc-400">{{ t('settings.claudeHomeLabel') }}</span>
|
||||
<input v-model.trim="claudeHomeDraft" class="input font-mono" :placeholder="t('settings.claudeHomePlaceholder')" />
|
||||
<span class="text-xs text-zinc-500">{{ t('settings.claudeHomeHint') }}</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<BaseButton variant="primary" :loading="settings.saving" :disabled="!claudeDirty" @click="saveClaude">
|
||||
{{ t('settings.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sécurité & conformité -->
|
||||
<section class="card flex flex-col gap-3">
|
||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||
@@ -202,7 +240,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Bell, BellOff, Check, Coffee, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Trash2, X } from '@lucide/vue';
|
||||
import { AlertCircle, Bell, BellOff, Check, CheckCircle2, Coffee, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Terminal, Trash2, X } from '@lucide/vue';
|
||||
import type {
|
||||
AuditLogEntry,
|
||||
AuditLogsResponse,
|
||||
@@ -328,6 +366,27 @@ async function saveDiscovery(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Claude CLI ----
|
||||
const claudeBinDraft = ref('');
|
||||
const claudeHomeDraft = ref('');
|
||||
const claudeBin = computed(() => settings.server?.claudeBin ?? { path: null, source: null, ok: false });
|
||||
const claudeDirty = computed(
|
||||
() => claudeBinDraft.value !== (settings.claudeBinPath ?? '') || claudeHomeDraft.value !== (settings.claudeHome ?? ''),
|
||||
);
|
||||
function syncClaudeDraft(): void {
|
||||
claudeBinDraft.value = settings.claudeBinPath ?? '';
|
||||
claudeHomeDraft.value = settings.claudeHome ?? '';
|
||||
}
|
||||
async function saveClaude(): Promise<void> {
|
||||
try {
|
||||
await settings.save({ claudeBinPath: claudeBinDraft.value, claudeHome: claudeHomeDraft.value });
|
||||
syncClaudeDraft();
|
||||
toasts.success(t('settings.saved'));
|
||||
} catch (e) {
|
||||
toasts.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Sécurité & conformité (audit / RGPD) ----
|
||||
const audit = ref<AuditLogEntry[]>([]);
|
||||
const auditing = ref(false);
|
||||
@@ -378,6 +437,7 @@ async function deleteMyData(): Promise<void> {
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
|
||||
syncDiscoveryDraft();
|
||||
syncClaudeDraft();
|
||||
void push.refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -185,6 +185,10 @@ const en: HelpSection[] = [
|
||||
title: 'Source code',
|
||||
body: 'The “Source code” icon in the navigation always links to the Arboretum source repository on Gitea.',
|
||||
},
|
||||
{
|
||||
title: 'VS Code extension',
|
||||
body: 'Prefer your editor? A native VS Code extension (not a webview) connects to the same daemon: a live tree of repos/worktrees/sessions, native terminals to attach to any session, a waiting counter with notifications, and git actions — right inside VS Code. It is distributed as a private VSIX; build it with “npm run build:vscode” then package with vsce, install via “Install from VSIX”, and sign in with a token.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -361,6 +365,10 @@ const fr: HelpSection[] = [
|
||||
title: 'Code source',
|
||||
body: 'L’icône « Code source » de la navigation pointe toujours vers le dépôt source d’Arboretum sur Gitea.',
|
||||
},
|
||||
{
|
||||
title: 'Extension VS Code',
|
||||
body: 'Vous préférez votre éditeur ? Une extension VS Code native (pas un webview) se connecte au même daemon : un arbre en direct des repos/worktrees/sessions, des terminaux natifs pour s’attacher à n’importe quelle session, un compteur d’attente avec notifications, et les actions git — directement dans VS Code. Distribuée en VSIX privé : buildez avec « npm run build:vscode » puis packagez avec vsce, installez via « Installer à partir d’un VSIX », et connectez-vous avec un token.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
53
packages/web/test/ws-client.test.ts
Normal file
53
packages/web/test/ws-client.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Flush ACK ciblé (Attachment.flushAck) : relance un PTY mis en pause au retour-visible d'une cellule.
|
||||
// On teste la logique d'ACK sans WebSocket réel (sendControl est public → stubbable).
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { WsClient, Attachment, type TerminalSink } from '../src/lib/ws-client';
|
||||
|
||||
const sink: TerminalSink = {
|
||||
write: () => {},
|
||||
reset: () => {},
|
||||
onDetached: () => {},
|
||||
onControlChanged: () => {},
|
||||
};
|
||||
|
||||
describe('ws-client — flush ACK ciblé (Attachment.flushAck)', () => {
|
||||
it('envoie un unique ACK cumulatif du reliquat traité, puis est idempotent', () => {
|
||||
const client = new WsClient();
|
||||
const send = vi.spyOn(client, 'sendControl').mockImplementation(() => {});
|
||||
const att = new Attachment(client, 'sid', 'interactive', sink, 80, 24);
|
||||
att.channel = 2;
|
||||
att.processedBytes = 1000;
|
||||
|
||||
att.flushAck();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send).toHaveBeenCalledWith({ type: 'ack', channel: 2, bytes: 1000 });
|
||||
expect(att.lastAckBytes).toBe(1000);
|
||||
|
||||
// rien de neuf traité → aucun ACK supplémentaire (n'ACK QUE du réellement traité)
|
||||
att.flushAck();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
|
||||
// nouveau reliquat traité → ACK cumulatif mis à jour
|
||||
att.processedBytes = 1500;
|
||||
att.flushAck();
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(send).toHaveBeenLastCalledWith({ type: 'ack', channel: 2, bytes: 1500 });
|
||||
});
|
||||
|
||||
it('no-op si le canal n’est pas attaché (-1) ou si l’attachment est fermé', () => {
|
||||
const client = new WsClient();
|
||||
const send = vi.spyOn(client, 'sendControl').mockImplementation(() => {});
|
||||
|
||||
const notAttached = new Attachment(client, 'a', 'interactive', sink, 80, 24); // channel = -1
|
||||
notAttached.processedBytes = 500;
|
||||
notAttached.flushAck();
|
||||
|
||||
const closed = new Attachment(client, 'b', 'interactive', sink, 80, 24);
|
||||
closed.channel = 3;
|
||||
closed.processedBytes = 500;
|
||||
closed.closed = true;
|
||||
closed.flushAck();
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
131
spikes/s5-vscode/STUDY.md
Normal file
131
spikes/s5-vscode/STUDY.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Spike S5 — Extension VSCode (intégration native) — ÉTUDE : ✅ GO (phasé)
|
||||
|
||||
> **✅ IMPLÉMENTÉ** dans `packages/vscode` (`git-arboretum`, privé) — phases A→D livrées : connexion +
|
||||
> auth SecretStorage, arbres temps réel (Repos/Groups), terminaux natifs `Pseudoterminal`, status bar +
|
||||
> notifications `waiting` (commande `answer`), mutations git, conscience du workspace. Bundle esbuild
|
||||
> (shared inliné), packagé en VSIX (`npm run build:vscode` puis `vsce package --no-dependencies`).
|
||||
> CI : `.gitea/workflows/vscode-release.yml` sur tag `vscode-vX.Y.Z`. L'étude ci-dessous reste la
|
||||
> justification de conception.
|
||||
|
||||
|
||||
Étude de conception (pas de mesure Go/No-Go : c'est un design + plan d'implémentation phasé). Objectif
|
||||
validé avec l'utilisateur : **une vraie intégration native dans VSCode, pas un simple webview** — une
|
||||
connexion réelle avec ce qui se passe dans l'éditeur (terminaux natifs, arbre, statut, notifications,
|
||||
conscience du workspace).
|
||||
|
||||
## Contexte & contrainte
|
||||
|
||||
Arboretum est déjà un daemon qui sert une SPA et expose :
|
||||
- une **API REST** (`/api/v1/...`) : repos, worktrees (+ mutations git commit/push/promote), sessions,
|
||||
groupes, auth ;
|
||||
- un **protocole WebSocket** multiplexé (`@arboretum/shared` → `protocol.ts`) : attach/stdin/resize,
|
||||
frames binaires `[type u8][channel u32le][payload]`, flow-control par watermarks (`FLOW`), events
|
||||
temps réel `sessions`/`worktrees`/`groups`, commande `answer` (dialogues), `hello`/`hello_ok`.
|
||||
|
||||
Un plugin n'a donc **rien à réimplémenter côté logique** : il est un **client** de cette API. La question
|
||||
n'est pas « comment afficher le dashboard » mais « comment exposer Arboretum avec les primitives natives
|
||||
de VSCode ». Le webview (iframe du dashboard) reste un *fallback* optionnel ; ce n'est pas le cœur.
|
||||
|
||||
## Pourquoi pas « juste un webview »
|
||||
|
||||
- Un webview = iframe vers `http://127.0.0.1:7317` : lourd, isolé (CSP stricte, `acquireVsCodeApi`
|
||||
indisponible dans l'iframe), pas d'intégration avec les terminaux/arbres/statut de VSCode, auth à
|
||||
re-bricoler (cookie/token dans une iframe cross-origin). On retrouve les bugs xterm-in-webview
|
||||
(rendu, scrollback) qu'on cherche justement à éviter.
|
||||
- Une intégration native réutilise le **rendu terminal, le scrollback, le copier/coller, les liens** de
|
||||
VSCode (gratuitement robustes), et place Arboretum là où l'utilisateur travaille déjà.
|
||||
|
||||
## Architecture proposée — `packages/vscode`
|
||||
|
||||
Nouveau workspace privé `@arboretum/vscode`, extension TypeScript, packagée en **VSIX** via `vsce`
|
||||
(build `esbuild`). Le `@arboretum/shared` est résolu en dev par le symlink workspace et **inliné** au
|
||||
packaging (même principe que `scripts/inline-shared.mjs` côté serveur) → VSIX autonome.
|
||||
|
||||
### Connexion au daemon
|
||||
- Réglages : `arboretum.url` (défaut `http://127.0.0.1:7317`), `arboretum.token`.
|
||||
- **Auth** : commande `Arboretum: Sign in` qui demande le token et le stocke dans **VS Code
|
||||
SecretStorage** (les tokens daemon sont stockés *hashés* et le bootstrap ne s'affiche qu'une fois →
|
||||
impossible de « lire » le token existant ; on le saisit une fois). Option avancée : commande qui
|
||||
**spawn** le daemon (`npx @johanleroy/git-arboretum`) et capture le bootstrap token au premier
|
||||
démarrage.
|
||||
- Le check Origin strict du daemon ne s'applique qu'aux navigateurs (en-tête `Origin`) : un client
|
||||
Node/extension n'envoie pas d'`Origin` → OK ; l'auth par token (en-tête/cookie) reste requise.
|
||||
|
||||
### Réutilisation du protocole (le « vrai » lien)
|
||||
Importer `@arboretum/shared` : `decodeBinaryFrame`/encodage, `BINARY_FRAME`, `FLOW`, `PROTOCOL_VERSION`,
|
||||
types de messages et `SessionSummary`/`WorktreeSummary`/`GroupSummary`. Le pont terminal et la liste
|
||||
restent **synchronisés** avec le serveur par construction.
|
||||
|
||||
### Surfaces natives
|
||||
|
||||
1. **Activity Bar + TreeView(s)** (`vscode.window.createTreeView`) : arbre **Repos → Worktrees →
|
||||
Sessions**, avec badges d'état fins (`activity` busy/waiting/idle, `dialog`) déjà portés par
|
||||
`SessionSummary`. Une vue **Groupes** en parallèle. Rafraîchi en temps réel par les events WS
|
||||
(`onDidChangeTreeData`).
|
||||
|
||||
2. **Terminaux VSCode natifs via `Pseudoterminal`** *(pièce maîtresse)* :
|
||||
`vscode.window.createTerminal({ name, pty })` où le `Pseudoterminal` ponte la session Arboretum :
|
||||
- `open()` → `wsClient.attach(sessionId)` ;
|
||||
- OUTPUT WS (frames binaires) → `writeEmitter.fire(text)` (`onDidWrite`) ;
|
||||
- `handleInput(data)` → WS `stdin` ;
|
||||
- `setDimensions({columns, rows})` → WS `resize` ;
|
||||
- `close()` → `detach`.
|
||||
On réutilise le **renderer + scrollback natifs de VSCode** : règle au passage, côté VSCode, les
|
||||
problèmes de rendu et d'historique de la SPA. Flow-control : réutiliser `FLOW`/les ACK du protocole
|
||||
(envoyer un ACK des octets écrits) pour ne pas saturer.
|
||||
|
||||
3. **Status bar** (`createStatusBarItem`) : compteur des sessions `waiting` (à traiter) ; clic → focus
|
||||
de l'arbre / quick-pick des sessions en attente.
|
||||
|
||||
4. **Notifications natives** : sur transition d'une session vers `waiting` (event WS), `showInformation
|
||||
Message` avec actions (« Ouvrir le terminal », « Oui »/« Non » → commande WS `answer`). Doublonne
|
||||
utilement le Web Push : ici, natif dans l'éditeur.
|
||||
|
||||
5. **Conscience du workspace** : mapper `workspace.workspaceFolders` → repo/worktree Arboretum (par
|
||||
chemin). Actions contextuelles : « Démarrer une session Claude ici », « Créer un worktree pour ce
|
||||
repo », et les mutations git existantes (commit/push/promote via REST). Met aussi en évidence dans
|
||||
l'arbre le worktree correspondant au dossier ouvert.
|
||||
|
||||
6. **Commandes** (`contributes.commands` + palette VSCode) : sign in, ouvrir le dashboard (webview
|
||||
*optionnel*), rafraîchir, attacher/observer une session, créer worktree, commit/push/promote,
|
||||
start/hide session, répondre à un dialogue.
|
||||
|
||||
7. **Webview** : seulement en *option* (vue dashboard complète pour qui la veut). UX primaire = native.
|
||||
|
||||
## Découpage phasé (livrable séparé — non implémenté dans ce lot)
|
||||
|
||||
- **A — Lecture & connexion** : auth (SecretStorage) + client REST/WS + TreeView lecture seule
|
||||
(repos/worktrees/sessions/groupes) en temps réel. *Valeur immédiate, risque faible.*
|
||||
- **B — Terminaux natifs** : `Pseudoterminal` attach/observe via pont WS réutilisant `@arboretum/shared`
|
||||
(flow-control inclus). *Le cœur de la valeur ; à faire juste après A.*
|
||||
- **C — Actions & supervision** : mutations (créer worktree, commit/push/promote, start/hide), status
|
||||
bar `waiting`, notifications + commande `answer`.
|
||||
- **D — Workspace-aware** : mapping dossier ↔ repo/worktree + actions contextuelles.
|
||||
|
||||
## Intégration monorepo & packaging
|
||||
|
||||
- `packages/vscode/` : `package.json` d'extension (`engines.vscode`, `activationEvents`,
|
||||
`contributes.{commands,views,viewsContainers,configuration}`), build `esbuild` (bundle Node), test via
|
||||
`@vscode/test-electron` (optionnel). Publication : VSIX (`vsce package`) — le registre privé Gitea ne
|
||||
sert pas d'extensions VSCode, donc distribution par VSIX (et/ou Open VSX) plutôt que le Marketplace si
|
||||
l'on veut rester privé.
|
||||
- Le `tsconfig` reste hors du `tsc -b` server/shared actuel (build séparé) pour ne pas alourdir le
|
||||
typecheck CI existant.
|
||||
|
||||
## Risques / questions ouvertes
|
||||
|
||||
- **Auth bootstrap** : saisie manuelle du token vs spawn+capture. MVP = saisie manuelle (SecretStorage).
|
||||
- **Flow-control côté extension** : reprendre `FLOW` (ACK par octets) pour les terminaux natifs ; un
|
||||
`Pseudoterminal` n'expose pas de back-pressure → ACK « ce qui a été émis dans `onDidWrite` ».
|
||||
- **Remote/SSH/Codespaces** : si l'on veut le webview fallback, prévoir `vscode.env.asExternalUri` +
|
||||
`portMapping`. Les surfaces natives (REST/WS via `arboretum.url`) restent valides tant que le daemon
|
||||
est joignable depuis l'hôte de l'extension.
|
||||
- **Multi-fenêtres VSCode** : une connexion WS par fenêtre (acceptable, outil mono-utilisateur).
|
||||
|
||||
## Verdict
|
||||
|
||||
**GO** sur une extension native `packages/vscode`, en commençant par **A → B** (TreeView temps réel +
|
||||
terminaux natifs `Pseudoterminal`), qui apportent l'essentiel de la valeur et réutilisent directement
|
||||
l'API REST + le protocole WS de `@arboretum/shared`. Le webview n'est qu'un fallback optionnel.
|
||||
Références API : Webview, Tree View, Pseudoterminal/`createTerminal`, StatusBarItem, SecretStorage,
|
||||
`asExternalUri` (docs officielles VSCode).
|
||||
Reference in New Issue
Block a user