feat(vscode): statut git détaillé, sessions archivées, lien IDE web, fetch/pull (0.2.0)

Reflète la refonte IDE en supervision légère, sans dupliquer l'éditeur :
- statut git fin dans l'arbre (staged/unstaged/conflits + dernier commit) ;
- commande « Open Worktree IDE » → deep-link /workspace/:repoId/:wt ;
- commandes fetch / pull (ff-only|rebase) sur les worktrees ;
- event WS session_archived + réglage showArchivedSessions (badge + filtre).
README/CHANGELOG + tests (config/rest-client/ws-client).
This commit is contained in:
2026-06-27 16:04:28 +02:00
parent 8a8fac75e6
commit d4e3ab47cd
13 changed files with 198 additions and 10 deletions

View File

@@ -1,5 +1,17 @@
# Changelog # Changelog
## 0.2.0
Follows the daemon's "worktree IDE" milestone (P7→P12), while keeping the extension a lightweight
**visual worktree manager** — no editor/diff duplicated here, that lives in the web IDE:
- **Detailed git status** in the worktree tree: staged / unstaged / conflict counts and the last commit
subject (from the daemon's enriched `WorktreeGitStatus`).
- **Open Worktree IDE** command — deep-links to the web `/workspace/:repoId/:wt` view.
- **Fetch** and **Pull** (fast-forward only or rebase) commands on worktrees, alongside commit/push.
- **Archived sessions**: the `session_archived` event is handled live; finished, auto-archived sessions
are badged and hidden by default, with a new `arboretum.showArchivedSessions` setting to reveal them.
## 0.1.0 ## 0.1.0
Initial release — native VS Code integration for the Arboretum daemon: Initial release — native VS Code integration for the Arboretum daemon:

View File

@@ -9,7 +9,11 @@ integration on top of the [Arboretum](https://git.lidge.fr/johanleroy/arboretum)
daemon's PTY) — you get VS Code's own rendering, scrollback, copy/paste and links for free. 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 - **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). 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. - **Git mutations** from the tree: create worktree, commit, push, fetch, pull, promote to main — with a
**detailed git status** on each worktree (staged / unstaged / conflicts and the last commit subject).
- **Open Worktree IDE**: jump from any worktree to its full `/workspace` view (file tree, Monaco editor,
inline diffs, integrated terminal) in the browser. The extension stays a lightweight visual worktree
manager — the heavy editing lives in the web IDE.
- **Workspace-aware**: the worktree matching your open folder is highlighted; start a session or create a - **Workspace-aware**: the worktree matching your open folder is highlighted; start a session or create a
worktree for the current folder in one command. worktree for the current folder in one command.
@@ -33,6 +37,7 @@ integration on top of the [Arboretum](https://git.lidge.fr/johanleroy/arboretum)
| --- | --- | --- | | --- | --- | --- |
| `arboretum.url` | `http://127.0.0.1:7317` | Base URL of the daemon (REST + WebSocket). | | `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.showExternalSessions` | `false` | Also show Claude sessions started outside Arboretum (CLI). |
| `arboretum.showArchivedSessions` | `false` | Also show finished sessions auto-archived after their retention window. |
| `arboretum.notifyOnWaiting` | `true` | Native notification when a session starts waiting for input. | | `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. The extension authenticates with `Authorization: Bearer <token>` on both REST and the WebSocket upgrade.
@@ -48,13 +53,13 @@ The extension is bundled with esbuild (`@arboretum/shared` is inlined → the VS
npm install npm install
npm run build:vscode # builds @arboretum/shared then bundles the extension npm run build:vscode # builds @arboretum/shared then bundles the extension
cd packages/vscode && npx @vscode/vsce package --no-dependencies cd packages/vscode && npx @vscode/vsce package --no-dependencies
# → git-arboretum-0.1.0.vsix # → git-arboretum-0.2.0.vsix
``` ```
Install it with **Extensions: Install from VSIX…** in the Command Palette, or: Install it with **Extensions: Install from VSIX…** in the Command Palette, or:
```bash ```bash
code --install-extension git-arboretum-0.1.0.vsix # also: codium / cursor code --install-extension git-arboretum-0.2.0.vsix # also: codium / cursor
``` ```
### Other distribution channels (optional) ### Other distribution channels (optional)

View File

@@ -2,7 +2,7 @@
"name": "git-arboretum", "name": "git-arboretum",
"displayName": "Arboretum", "displayName": "Arboretum",
"description": "Pilot your git worktrees and Claude Code sessions from VS Code — native terminals, live tree, waiting alerts.", "description": "Pilot your git worktrees and Claude Code sessions from VS Code — native terminals, live tree, waiting alerts.",
"version": "0.1.1", "version": "0.2.0",
"private": true, "private": true,
"publisher": "johanleroy", "publisher": "johanleroy",
"license": "MIT", "license": "MIT",
@@ -42,6 +42,11 @@
"default": false, "default": false,
"markdownDescription": "Show Claude sessions discovered outside Arboretum (started from the CLI) in the trees. Off by default to avoid clutter." "markdownDescription": "Show Claude sessions discovered outside Arboretum (started from the CLI) in the trees. Off by default to avoid clutter."
}, },
"arboretum.showArchivedSessions": {
"type": "boolean",
"default": false,
"markdownDescription": "Show finished sessions that Arboretum auto-archived after their retention window. Off by default to keep the trees focused on live work."
},
"arboretum.notifyOnWaiting": { "arboretum.notifyOnWaiting": {
"type": "boolean", "type": "boolean",
"default": true, "default": true,
@@ -94,7 +99,10 @@
{ "command": "arboretum.createWorktree", "title": "Create Worktree…", "category": "Arboretum", "icon": "$(add)" }, { "command": "arboretum.createWorktree", "title": "Create Worktree…", "category": "Arboretum", "icon": "$(add)" },
{ "command": "arboretum.commitWorktree", "title": "Commit…", "category": "Arboretum", "icon": "$(git-commit)" }, { "command": "arboretum.commitWorktree", "title": "Commit…", "category": "Arboretum", "icon": "$(git-commit)" },
{ "command": "arboretum.pushWorktree", "title": "Push", "category": "Arboretum", "icon": "$(repo-push)" }, { "command": "arboretum.pushWorktree", "title": "Push", "category": "Arboretum", "icon": "$(repo-push)" },
{ "command": "arboretum.fetchWorktree", "title": "Fetch", "category": "Arboretum", "icon": "$(cloud-download)" },
{ "command": "arboretum.pullWorktree", "title": "Pull…", "category": "Arboretum", "icon": "$(arrow-down)" },
{ "command": "arboretum.promoteWorktree", "title": "Promote to Main", "category": "Arboretum", "icon": "$(arrow-up)" }, { "command": "arboretum.promoteWorktree", "title": "Promote to Main", "category": "Arboretum", "icon": "$(arrow-up)" },
{ "command": "arboretum.openWorktreeIde", "title": "Open Worktree IDE", "category": "Arboretum", "icon": "$(link-external)" },
{ "command": "arboretum.startSession", "title": "Start Claude Session", "category": "Arboretum", "icon": "$(play)" }, { "command": "arboretum.startSession", "title": "Start Claude Session", "category": "Arboretum", "icon": "$(play)" },
{ "command": "arboretum.startGroupSession", "title": "Start Group 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.startSessionHere", "title": "Start Session in Current Folder", "category": "Arboretum" },
@@ -111,9 +119,13 @@
"view/item/context": [ "view/item/context": [
{ "command": "arboretum.createWorktree", "when": "viewItem == arboretum:repo", "group": "inline" }, { "command": "arboretum.createWorktree", "when": "viewItem == arboretum:repo", "group": "inline" },
{ "command": "arboretum.startSession", "when": "viewItem == arboretum:repo", "group": "1_session" }, { "command": "arboretum.startSession", "when": "viewItem == arboretum:repo", "group": "1_session" },
{ "command": "arboretum.openWorktreeIde", "when": "viewItem == arboretum:worktree", "group": "inline" },
{ "command": "arboretum.commitWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" }, { "command": "arboretum.commitWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" },
{ "command": "arboretum.pushWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" }, { "command": "arboretum.pushWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" },
{ "command": "arboretum.fetchWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" },
{ "command": "arboretum.pullWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" },
{ "command": "arboretum.promoteWorktree", "when": "viewItem == arboretum:worktree", "group": "2_git" }, { "command": "arboretum.promoteWorktree", "when": "viewItem == arboretum:worktree", "group": "2_git" },
{ "command": "arboretum.openWorktreeIde", "when": "viewItem == arboretum:worktree", "group": "2_git" },
{ "command": "arboretum.startSession", "when": "viewItem == arboretum:worktree", "group": "3_session" }, { "command": "arboretum.startSession", "when": "viewItem == arboretum:worktree", "group": "3_session" },
{ "command": "arboretum.attachSession", "when": "viewItem =~ /arboretum:session:live/", "group": "inline" }, { "command": "arboretum.attachSession", "when": "viewItem =~ /arboretum:session:live/", "group": "inline" },
{ "command": "arboretum.observeSession", "when": "viewItem =~ /arboretum:session:live/", "group": "1_term" }, { "command": "arboretum.observeSession", "when": "viewItem =~ /arboretum:session:live/", "group": "1_term" },
@@ -137,7 +149,10 @@
{ "command": "arboretum.createWorktree", "when": "false" }, { "command": "arboretum.createWorktree", "when": "false" },
{ "command": "arboretum.commitWorktree", "when": "false" }, { "command": "arboretum.commitWorktree", "when": "false" },
{ "command": "arboretum.pushWorktree", "when": "false" }, { "command": "arboretum.pushWorktree", "when": "false" },
{ "command": "arboretum.fetchWorktree", "when": "false" },
{ "command": "arboretum.pullWorktree", "when": "false" },
{ "command": "arboretum.promoteWorktree", "when": "false" }, { "command": "arboretum.promoteWorktree", "when": "false" },
{ "command": "arboretum.openWorktreeIde", "when": "false" },
{ "command": "arboretum.startSession", "when": "false" }, { "command": "arboretum.startSession", "when": "false" },
{ "command": "arboretum.startGroupSession", "when": "false" }, { "command": "arboretum.startGroupSession", "when": "false" },
{ "command": "arboretum.revealWorktree", "when": "false" } { "command": "arboretum.revealWorktree", "when": "false" }

View File

@@ -9,10 +9,12 @@ import type {
CreateSessionRequest, CreateSessionRequest,
CreateWorktreeRequest, CreateWorktreeRequest,
CreateWorktreeResponse, CreateWorktreeResponse,
FetchWorktreeRequest,
GroupSessionResponse, GroupSessionResponse,
GroupsListResponse, GroupsListResponse,
MeResponse, MeResponse,
PromoteWorktreeRequest, PromoteWorktreeRequest,
PullWorktreeRequest,
PushWorktreeRequest, PushWorktreeRequest,
RepoBranchesResponse, RepoBranchesResponse,
ReposListResponse, ReposListResponse,
@@ -93,6 +95,14 @@ export class RestClient {
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/push`, body); return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/push`, body);
} }
fetchWorktree(repoId: string, body: FetchWorktreeRequest): Promise<WorktreeResponse> {
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/fetch`, body);
}
pullWorktree(repoId: string, body: PullWorktreeRequest): Promise<WorktreeResponse> {
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/pull`, body);
}
promoteWorktree(repoId: string, body: PromoteWorktreeRequest): Promise<WorktreeResponse> { promoteWorktree(repoId: string, body: PromoteWorktreeRequest): Promise<WorktreeResponse> {
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/promote`, body); return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/promote`, body);
} }
@@ -106,9 +116,12 @@ export class RestClient {
return this.request<SessionResponse>('POST', '/api/v1/sessions', body); return this.request<SessionResponse>('POST', '/api/v1/sessions', body);
} }
listSessions(includeHidden = false): Promise<SessionsListResponse> { listSessions(opts: { includeHidden?: boolean; includeArchived?: boolean } = {}): Promise<SessionsListResponse> {
const q = includeHidden ? '?includeHidden=true' : ''; const params = new URLSearchParams();
return this.request<SessionsListResponse>('GET', `/api/v1/sessions${q}`); if (opts.includeHidden) params.set('includeHidden', 'true');
if (opts.includeArchived) params.set('includeArchived', 'true');
const q = params.toString();
return this.request<SessionsListResponse>('GET', `/api/v1/sessions${q ? `?${q}` : ''}`);
} }
resumeSession(id: string): Promise<SessionResponse> { resumeSession(id: string): Promise<SessionResponse> {

View File

@@ -115,6 +115,8 @@ export interface ArbWsClientOptions {
onSession?: (e: SessionEvent) => void; onSession?: (e: SessionEvent) => void;
onWorktree?: (e: WorktreeEvent) => void; onWorktree?: (e: WorktreeEvent) => void;
onGroup?: (e: GroupEvent) => void; onGroup?: (e: GroupEvent) => void;
/** P10 : une session managée terminée vient d'être auto-archivée. */
onSessionArchived?: (sessionId: string) => void;
/** appelé au premier hello_ok après (re)connexion → re-seed du store via REST. */ /** appelé au premier hello_ok après (re)connexion → re-seed du store via REST. */
onReady?: () => void; onReady?: () => void;
log?: (msg: string) => void; log?: (msg: string) => void;
@@ -387,6 +389,9 @@ export class ArbWsClient {
case 'session_exit': case 'session_exit':
this.opts.onSession?.(msg); this.opts.onSession?.(msg);
return; return;
case 'session_archived':
this.opts.onSessionArchived?.(msg.sessionId);
return;
case 'repo_update': case 'repo_update':
case 'repo_removed': case 'repo_removed':
case 'worktree_update': case 'worktree_update':

View File

@@ -4,6 +4,7 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared'; import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import { RestError, type RestClient } from './api/rest-client.js'; import { RestError, type RestClient } from './api/rest-client.js';
import { workspaceUrl } from './config.js';
import type { ArbWsClient, AttachmentSink } from './api/ws-client.js'; import type { ArbWsClient, AttachmentSink } from './api/ws-client.js';
import type { Store } from './state/store.js'; import type { Store } from './state/store.js';
import type { SessionTerminalManager } from './terminal/session-pty.js'; import type { SessionTerminalManager } from './terminal/session-pty.js';
@@ -174,10 +175,43 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command
}), }),
); );
}); });
cmd('arboretum.fetchWorktree', (node) => {
const wt = asWorktree(node as AnyNode);
if (!wt) return;
void vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Arboretum: fetching…' }, () =>
run('fetch', async () => {
await rest.fetchWorktree(wt.repoId, { path: wt.worktree.path });
void vscode.window.showInformationMessage('Arboretum: fetched.');
}),
);
});
cmd('arboretum.pullWorktree', (node) => {
const wt = asWorktree(node as AnyNode);
if (!wt) return;
void run('pull', async () => {
const mode = await vscode.window.showQuickPick(
[
{ label: 'Fast-forward only', mode: 'ff-only' as const },
{ label: 'Rebase', mode: 'rebase' as const },
],
{ placeHolder: 'Pull strategy' },
);
if (!mode) return;
await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Arboretum: pulling…' }, async () => {
await rest.pullWorktree(wt.repoId, { path: wt.worktree.path, mode: mode.mode });
void vscode.window.showInformationMessage('Arboretum: pulled.');
});
});
});
cmd('arboretum.promoteWorktree', (node) => { cmd('arboretum.promoteWorktree', (node) => {
const wt = asWorktree(node as AnyNode); const wt = asWorktree(node as AnyNode);
if (wt) void promoteWorktreeFlow(deps, wt.repoId, wt.worktree); if (wt) void promoteWorktreeFlow(deps, wt.repoId, wt.worktree);
}); });
cmd('arboretum.openWorktreeIde', (node) => {
const wt = asWorktree(node as AnyNode);
if (!wt) return;
void vscode.env.openExternal(vscode.Uri.parse(workspaceUrl(rest.url, wt.repoId, wt.worktree.path)));
});
// ---- démarrage de session ---- // ---- démarrage de session ----
cmd('arboretum.startSession', (node) => { cmd('arboretum.startSession', (node) => {

View File

@@ -5,6 +5,7 @@
export interface ConfigSnapshot { export interface ConfigSnapshot {
url: string; url: string;
showExternalSessions: boolean; showExternalSessions: boolean;
showArchivedSessions: boolean;
notifyOnWaiting: boolean; notifyOnWaiting: boolean;
} }
@@ -19,3 +20,12 @@ export function wsUrlFromBase(baseUrl: string): string {
const base = normalizeBaseUrl(baseUrl); const base = normalizeBaseUrl(baseUrl);
return `${base.replace(/^http/, 'ws')}/ws`; return `${base.replace(/^http/, 'ws')}/ws`;
} }
/**
* Construit l'URL de la vue IDE web d'un worktree — deep-link vers la route `/workspace/:repoId/:wt`
* (le routeur web est en history mode). `repoId` et le chemin du worktree sont encodés par segment.
*/
export function workspaceUrl(baseUrl: string, repoId: string, worktreePath: string): string {
const base = normalizeBaseUrl(baseUrl);
return `${base}/workspace/${encodeURIComponent(repoId)}/${encodeURIComponent(worktreePath)}`;
}

View File

@@ -21,6 +21,9 @@ function readUrl(): string {
function readShowExternal(): boolean { function readShowExternal(): boolean {
return vscode.workspace.getConfiguration('arboretum').get<boolean>('showExternalSessions', false); return vscode.workspace.getConfiguration('arboretum').get<boolean>('showExternalSessions', false);
} }
function readShowArchived(): boolean {
return vscode.workspace.getConfiguration('arboretum').get<boolean>('showArchivedSessions', false);
}
function readNotify(): boolean { function readNotify(): boolean {
return vscode.workspace.getConfiguration('arboretum').get<boolean>('notifyOnWaiting', true); return vscode.workspace.getConfiguration('arboretum').get<boolean>('notifyOnWaiting', true);
} }
@@ -36,6 +39,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
const auth = new Auth(context.secrets); const auth = new Auth(context.secrets);
const store = new Store(); const store = new Store();
store.showExternalSessions = readShowExternal(); store.showExternalSessions = readShowExternal();
store.showArchivedSessions = readShowArchived();
const rest = new RestClient({ baseUrl: readUrl() }); const rest = new RestClient({ baseUrl: readUrl() });
const reseed = async (): Promise<void> => { const reseed = async (): Promise<void> => {
@@ -54,6 +58,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
onSession: (e) => store.applySession(e), onSession: (e) => store.applySession(e),
onWorktree: (e) => store.applyWorktree(e), onWorktree: (e) => store.applyWorktree(e),
onGroup: (e) => store.applyGroup(e), onGroup: (e) => store.applyGroup(e),
onSessionArchived: (id) => store.archiveSession(id),
onReady: () => void reseed(), onReady: () => void reseed(),
onStatus: (s) => { onStatus: (s) => {
const connected = s === 'open'; const connected = s === 'open';
@@ -132,6 +137,10 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
if (e.affectsConfiguration('arboretum.showExternalSessions')) { if (e.affectsConfiguration('arboretum.showExternalSessions')) {
store.setShowExternalSessions(readShowExternal()); store.setShowExternalSessions(readShowExternal());
} }
if (e.affectsConfiguration('arboretum.showArchivedSessions')) {
store.setShowArchivedSessions(readShowArchived());
await reseed(); // recharge la liste avec/sans les sessions archivées (includeArchived)
}
}), }),
); );

View File

@@ -27,6 +27,8 @@ export class Store {
/** affiche aussi les sessions externes (découvertes hors Arboretum). */ /** affiche aussi les sessions externes (découvertes hors Arboretum). */
showExternalSessions = false; showExternalSessions = false;
/** affiche aussi les sessions managées auto-archivées par ancienneté (P10). */
showArchivedSessions = false;
onDidChange(cb: () => void): () => void { onDidChange(cb: () => void): () => void {
this.listeners.add(cb); this.listeners.add(cb);
@@ -40,6 +42,13 @@ export class Store {
this.emit(); this.emit();
} }
/** Bascule l'affichage des sessions archivées (le re-seed REST est piloté par l'extension). */
setShowArchivedSessions(value: boolean): void {
if (this.showArchivedSessions === value) return;
this.showArchivedSessions = value;
this.emit();
}
private emit(): void { private emit(): void {
for (const cb of this.listeners) cb(); for (const cb of this.listeners) cb();
} }
@@ -49,7 +58,7 @@ export class Store {
const [repos, worktrees, sessions, groups] = await Promise.all([ const [repos, worktrees, sessions, groups] = await Promise.all([
rest.listRepos(), rest.listRepos(),
rest.listWorktrees(), rest.listWorktrees(),
rest.listSessions(false), rest.listSessions({ includeArchived: this.showArchivedSessions }),
rest.listGroups(), rest.listGroups(),
]); ]);
this.repos.clear(); this.repos.clear();
@@ -95,6 +104,14 @@ export class Store {
this.emit(); this.emit();
} }
/** P10 : une session managée terminée vient d'être auto-archivée (message WS `session_archived`). */
archiveSession(sessionId: string): void {
const prev = this.sessions.get(sessionId);
if (!prev) return;
this.sessions.set(sessionId, { ...prev, archived: true });
this.emit();
}
applyWorktree(e: WorktreeEvent): void { applyWorktree(e: WorktreeEvent): void {
switch (e.type) { switch (e.type) {
case 'repo_update': case 'repo_update':
@@ -152,6 +169,7 @@ export class Store {
return [...this.sessions.values()] return [...this.sessions.values()]
.filter((s) => normPath(s.cwd) === wp) .filter((s) => normPath(s.cwd) === wp)
.filter((s) => this.showExternalSessions || s.source === 'managed') .filter((s) => this.showExternalSessions || s.source === 'managed')
.filter((s) => this.showArchivedSessions || !s.archived)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
} }
@@ -172,6 +190,7 @@ export class Store {
return [...this.sessions.values()] return [...this.sessions.values()]
.filter((s) => s.groupId === groupId) .filter((s) => s.groupId === groupId)
.filter((s) => this.showExternalSessions || s.source === 'managed') .filter((s) => this.showExternalSessions || s.source === 'managed')
.filter((s) => this.showArchivedSessions || !s.archived)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
} }

View File

@@ -21,7 +21,7 @@ export function worktreeTreeItem(worktree: WorktreeSummary): vscode.TreeItem {
item.contextValue = 'arboretum:worktree'; item.contextValue = 'arboretum:worktree';
item.iconPath = new vscode.ThemeIcon(worktree.isMain ? 'star-full' : 'git-branch'); item.iconPath = new vscode.ThemeIcon(worktree.isMain ? 'star-full' : 'git-branch');
item.description = worktreeStatus(worktree); item.description = worktreeStatus(worktree);
item.tooltip = worktree.path; item.tooltip = worktreeTooltip(worktree);
item.resourceUri = vscode.Uri.file(worktree.path); item.resourceUri = vscode.Uri.file(worktree.path);
return item; return item;
} }
@@ -32,9 +32,28 @@ function worktreeStatus(w: WorktreeSummary): string {
if (w.git.ahead > 0) parts.push(`${w.git.ahead}`); if (w.git.ahead > 0) parts.push(`${w.git.ahead}`);
if (w.git.behind > 0) parts.push(`${w.git.behind}`); if (w.git.behind > 0) parts.push(`${w.git.behind}`);
if (w.git.dirtyCount > 0) parts.push(`${w.git.dirtyCount}`); if (w.git.dirtyCount > 0) parts.push(`${w.git.dirtyCount}`);
// Compteurs fins (P7, additifs) : staged/conflits surfacés directement dans l'arbre.
if (w.git.stagedCount && w.git.stagedCount > 0) parts.push(`+${w.git.stagedCount}`);
if (w.git.conflictCount && w.git.conflictCount > 0) parts.push(`!${w.git.conflictCount}`);
return parts.join(' '); return parts.join(' ');
} }
/** Tooltip détaillé : chemin + statut git fin (P7) + dernier commit. */
function worktreeTooltip(w: WorktreeSummary): vscode.MarkdownString {
const md = new vscode.MarkdownString();
md.appendMarkdown(`\`${w.path}\`\n\n`);
const g = w.git;
const status: string[] = [];
if (g.stagedCount && g.stagedCount > 0) status.push(`${g.stagedCount} staged`);
if (g.unstagedCount && g.unstagedCount > 0) status.push(`${g.unstagedCount} unstaged`);
if (g.conflictCount && g.conflictCount > 0) status.push(`${g.conflictCount} conflicts`);
if (status.length === 0 && g.dirtyCount > 0) status.push(`${g.dirtyCount} changed`);
md.appendMarkdown(status.length > 0 ? status.join(' · ') : 'clean');
if (g.ahead > 0 || g.behind > 0) md.appendMarkdown(` (↑${g.ahead}${g.behind})`);
if (g.lastCommitSubject) md.appendMarkdown(`\n\n_${g.lastCommitSubject}_`);
return md;
}
export function sessionContextValue(s: SessionSummary): string { export function sessionContextValue(s: SessionSummary): string {
let v = `arboretum:session:${s.live ? 'live' : 'dead'}`; let v = `arboretum:session:${s.live ? 'live' : 'dead'}`;
if (s.source === 'discovered') v += ':discovered'; if (s.source === 'discovered') v += ':discovered';
@@ -72,6 +91,7 @@ function sessionDescription(s: SessionSummary): string {
// Affiche 'available' pour idle (cohérent avec le libellé web) ; l'enum reste 'idle' côté protocole. // 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); if (s.live && s.activity) parts.push(s.activity === 'idle' ? 'available' : s.activity);
else if (!s.live) parts.push('exited'); else if (!s.live) parts.push('exited');
if (s.archived) parts.push('archived');
if (s.source === 'discovered') parts.push('external'); if (s.source === 'discovered') parts.push('external');
if (s.groupId) parts.push('group'); if (s.groupId) parts.push('group');
if (s.waitingFor) parts.push(`${s.waitingFor}`); if (s.waitingFor) parts.push(`${s.waitingFor}`);

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { normalizeBaseUrl, wsUrlFromBase } from '../src/config.js'; import { normalizeBaseUrl, workspaceUrl, wsUrlFromBase } from '../src/config.js';
describe('config', () => { describe('config', () => {
it('normalise la base URL (slash final, défaut)', () => { it('normalise la base URL (slash final, défaut)', () => {
@@ -12,4 +12,11 @@ describe('config', () => {
expect(wsUrlFromBase('http://127.0.0.1:7317')).toBe('ws://127.0.0.1:7317/ws'); 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'); expect(wsUrlFromBase('https://box.ts.net')).toBe('wss://box.ts.net/ws');
}); });
it('construit lURL de la vue IDE web (workspaceUrl, encodage par segment)', () => {
expect(workspaceUrl('http://127.0.0.1:7317', 'repo id', '/home/user/wt')).toBe(
'http://127.0.0.1:7317/workspace/repo%20id/%2Fhome%2Fuser%2Fwt',
);
expect(workspaceUrl('https://box.ts.net/', 'r', '/a/b')).toBe('https://box.ts.net/workspace/r/%2Fa%2Fb');
});
}); });

View File

@@ -48,4 +48,31 @@ describe('RestClient', () => {
expect(captured[0]?.init.method).toBe('POST'); expect(captured[0]?.init.method).toBe('POST');
expect(JSON.parse(String(captured[0]?.init.body))).toEqual({ path: '/w', message: 'msg' }); expect(JSON.parse(String(captured[0]?.init.body))).toEqual({ path: '/w', message: 'msg' });
}); });
it('fetch/pull worktree : chemins et corps', async () => {
const captured: Captured[] = [];
const rest = new RestClient(
{ baseUrl: 'http://h:1', token: 't' },
fakeFetch({ ok: true, status: 200, body: { worktree: {} } }, captured),
);
await rest.fetchWorktree('r', { path: '/w' });
await rest.pullWorktree('r', { path: '/w', mode: 'rebase' });
expect(captured[0]?.url).toBe('http://h:1/api/v1/repos/r/worktrees/fetch');
expect(captured[1]?.url).toBe('http://h:1/api/v1/repos/r/worktrees/pull');
expect(JSON.parse(String(captured[1]?.init.body))).toEqual({ path: '/w', mode: 'rebase' });
});
it('listSessions construit la query includeHidden/includeArchived', async () => {
const captured: Captured[] = [];
const rest = new RestClient(
{ baseUrl: 'http://h:1', token: 't' },
fakeFetch({ ok: true, status: 200, body: { sessions: [] } }, captured),
);
await rest.listSessions();
await rest.listSessions({ includeArchived: true });
await rest.listSessions({ includeHidden: true, includeArchived: true });
expect(captured[0]?.url).toBe('http://h:1/api/v1/sessions');
expect(captured[1]?.url).toBe('http://h:1/api/v1/sessions?includeArchived=true');
expect(captured[2]?.url).toBe('http://h:1/api/v1/sessions?includeHidden=true&includeArchived=true');
});
}); });

View File

@@ -124,6 +124,18 @@ describe('ArbWsClient', () => {
expect(acks[acks.length - 1]).toMatchObject({ bytes: FLOW.ACK_EVERY_BYTES }); expect(acks[acks.length - 1]).toMatchObject({ bytes: FLOW.ACK_EVERY_BYTES });
}); });
it('route session_archived vers onSessionArchived', () => {
const archived: string[] = [];
const f = new FakeSocket();
const c = new ArbWsClient({ socketFactory: () => f, onSessionArchived: (id) => archived.push(id) });
c.configure('http://127.0.0.1:7317', 'tok');
c.connect();
f.fireOpen();
f.fireText({ type: 'hello_ok', protocol: 1, serverVersion: '2.0.0' });
f.fireText({ type: 'session_archived', sessionId: 'sess-42' });
expect(archived).toEqual(['sess-42']);
});
it('envoie stdin/resize sur le bon canal', async () => { it('envoie stdin/resize sur le bon canal', async () => {
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() }); 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 }); fake.fireText({ type: 'attached', channel: 5, sessionId: 'sess', mode: 'interactive', controlling: true });