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

@@ -9,10 +9,12 @@ import type {
CreateSessionRequest,
CreateWorktreeRequest,
CreateWorktreeResponse,
FetchWorktreeRequest,
GroupSessionResponse,
GroupsListResponse,
MeResponse,
PromoteWorktreeRequest,
PullWorktreeRequest,
PushWorktreeRequest,
RepoBranchesResponse,
ReposListResponse,
@@ -93,6 +95,14 @@ export class RestClient {
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> {
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);
}
listSessions(includeHidden = false): Promise<SessionsListResponse> {
const q = includeHidden ? '?includeHidden=true' : '';
return this.request<SessionsListResponse>('GET', `/api/v1/sessions${q}`);
listSessions(opts: { includeHidden?: boolean; includeArchived?: boolean } = {}): Promise<SessionsListResponse> {
const params = new URLSearchParams();
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> {

View File

@@ -115,6 +115,8 @@ export interface ArbWsClientOptions {
onSession?: (e: SessionEvent) => void;
onWorktree?: (e: WorktreeEvent) => 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. */
onReady?: () => void;
log?: (msg: string) => void;
@@ -387,6 +389,9 @@ export class ArbWsClient {
case 'session_exit':
this.opts.onSession?.(msg);
return;
case 'session_archived':
this.opts.onSessionArchived?.(msg.sessionId);
return;
case 'repo_update':
case 'repo_removed':
case 'worktree_update':

View File

@@ -4,6 +4,7 @@
import * as vscode from 'vscode';
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
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 { Store } from './state/store.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) => {
const wt = asWorktree(node as AnyNode);
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 ----
cmd('arboretum.startSession', (node) => {

View File

@@ -5,6 +5,7 @@
export interface ConfigSnapshot {
url: string;
showExternalSessions: boolean;
showArchivedSessions: boolean;
notifyOnWaiting: boolean;
}
@@ -19,3 +20,12 @@ export function wsUrlFromBase(baseUrl: string): string {
const base = normalizeBaseUrl(baseUrl);
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 {
return vscode.workspace.getConfiguration('arboretum').get<boolean>('showExternalSessions', false);
}
function readShowArchived(): boolean {
return vscode.workspace.getConfiguration('arboretum').get<boolean>('showArchivedSessions', false);
}
function readNotify(): boolean {
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 store = new Store();
store.showExternalSessions = readShowExternal();
store.showArchivedSessions = readShowArchived();
const rest = new RestClient({ baseUrl: readUrl() });
const reseed = async (): Promise<void> => {
@@ -54,6 +58,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
onSession: (e) => store.applySession(e),
onWorktree: (e) => store.applyWorktree(e),
onGroup: (e) => store.applyGroup(e),
onSessionArchived: (id) => store.archiveSession(id),
onReady: () => void reseed(),
onStatus: (s) => {
const connected = s === 'open';
@@ -132,6 +137,10 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
if (e.affectsConfiguration('arboretum.showExternalSessions')) {
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). */
showExternalSessions = false;
/** affiche aussi les sessions managées auto-archivées par ancienneté (P10). */
showArchivedSessions = false;
onDidChange(cb: () => void): () => void {
this.listeners.add(cb);
@@ -40,6 +42,13 @@ export class Store {
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 {
for (const cb of this.listeners) cb();
}
@@ -49,7 +58,7 @@ export class Store {
const [repos, worktrees, sessions, groups] = await Promise.all([
rest.listRepos(),
rest.listWorktrees(),
rest.listSessions(false),
rest.listSessions({ includeArchived: this.showArchivedSessions }),
rest.listGroups(),
]);
this.repos.clear();
@@ -95,6 +104,14 @@ export class Store {
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 {
switch (e.type) {
case 'repo_update':
@@ -152,6 +169,7 @@ export class Store {
return [...this.sessions.values()]
.filter((s) => normPath(s.cwd) === wp)
.filter((s) => this.showExternalSessions || s.source === 'managed')
.filter((s) => this.showArchivedSessions || !s.archived)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
@@ -172,6 +190,7 @@ export class Store {
return [...this.sessions.values()]
.filter((s) => s.groupId === groupId)
.filter((s) => this.showExternalSessions || s.source === 'managed')
.filter((s) => this.showArchivedSessions || !s.archived)
.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.iconPath = new vscode.ThemeIcon(worktree.isMain ? 'star-full' : 'git-branch');
item.description = worktreeStatus(worktree);
item.tooltip = worktree.path;
item.tooltip = worktreeTooltip(worktree);
item.resourceUri = vscode.Uri.file(worktree.path);
return item;
}
@@ -32,9 +32,28 @@ function worktreeStatus(w: WorktreeSummary): string {
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}`);
// 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(' ');
}
/** 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 {
let v = `arboretum:session:${s.live ? 'live' : 'dead'}`;
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.
if (s.live && s.activity) parts.push(s.activity === 'idle' ? 'available' : s.activity);
else if (!s.live) parts.push('exited');
if (s.archived) parts.push('archived');
if (s.source === 'discovered') parts.push('external');
if (s.groupId) parts.push('group');
if (s.waitingFor) parts.push(`${s.waitingFor}`);