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:
@@ -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> {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user