feat(p7): moteur git détaillé, API fichiers et watcher FS temps réel
Fondations de la refonte « vrai IDE IA worktree » (chantier P7→P12).
- git.ts : listChanges (status --porcelain=v2 -z + --numstat fusionnés),
fileDiff (diff unifié borné, refus binaire, untracked via --no-index),
stage/unstage/restore/clean, commitStaged, amendCommit (refus si poussé),
fetchRemote, pull (ff-only|rebase), lastCommit, resolveGitDir,
isSafeRelativePath ; worktreeStatus enrichi (compteurs staged/unstaged/
conflict + dernier commit), champs additifs WorktreeGitStatus.
- core/fs-watcher.ts : FsWatcherService (chokidar, refcount + pool LRU borné,
ignore .git sauf HEAD/index, débounce) → invalide factsCache, rediffuse
worktree_update et émet worktree_changes.
- WorktreeManager : getWorktreeChanges/getFileDiff/stage/unstage/discard/
fetch/pull/watch/unwatch/assertPathInWorktree ; commitWorktree { mode, amend }.
- routes/git.ts (changes/diff/stage/unstage/discard/fetch/pull) et
routes/files.ts (GET/PUT contenu fichier, bornage strict au worktree) ;
fs/list?includeFiles=1 (flag isFile).
- protocole ADDITIF (PROTOCOL_VERSION inchangé) : messages WS watch/unwatch
(validés dans parseClientMessage) + worktree_changes poussé ciblé par la
gateway ; front wsClient.watchWorktree + lib/git-api.ts + api.put.
- tests git/fs-watcher/protocole + acceptance-p7.mjs (ALL GREEN).
- dépendance : chokidar (serveur).
This commit is contained in:
@@ -29,6 +29,8 @@ export interface TerminalSink {
|
||||
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' }>;
|
||||
/** P7 — signal ciblé « le détail (changes/diff) d'un worktree regardé a changé ». */
|
||||
export type WorktreeChangesEvent = Extract<ServerMessage, { type: 'worktree_changes' }>;
|
||||
|
||||
export interface AttachOptions {
|
||||
sessionId: string;
|
||||
@@ -125,6 +127,8 @@ export class WsClient {
|
||||
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
||||
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
|
||||
private readonly groupListeners = new Set<(e: GroupEvent) => void>();
|
||||
/** P7 — abonnements ciblés au détail d'un worktree (clé repoId\0path → listeners). */
|
||||
private readonly worktreeChangesListeners = new Map<string, Set<(e: WorktreeChangesEvent) => void>>();
|
||||
|
||||
connect(): void {
|
||||
this.stopped = false;
|
||||
@@ -215,6 +219,32 @@ export class WsClient {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* P7 — observe le détail (changes/diff) d'un worktree précis : envoie `watch`, route les
|
||||
* `worktree_changes` correspondants vers `listener`, et ré-arme automatiquement après reconnexion.
|
||||
* La désinscription envoie `unwatch` quand plus aucun listener ne regarde ce worktree.
|
||||
*/
|
||||
watchWorktree(repoId: string, path: string, listener: (e: WorktreeChangesEvent) => void): () => void {
|
||||
const key = `${repoId}\0${path}`;
|
||||
let set = this.worktreeChangesListeners.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.worktreeChangesListeners.set(key, set);
|
||||
this.connect();
|
||||
this.sendControl({ type: 'watch', repoId, path });
|
||||
}
|
||||
set.add(listener);
|
||||
return () => {
|
||||
const s = this.worktreeChangesListeners.get(key);
|
||||
if (!s) return;
|
||||
s.delete(listener);
|
||||
if (s.size === 0) {
|
||||
this.worktreeChangesListeners.delete(key);
|
||||
this.sendControl({ type: 'unwatch', repoId, path });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
sendControl(msg: ClientMessage): void {
|
||||
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return;
|
||||
this.socket.send(JSON.stringify(msg));
|
||||
@@ -389,6 +419,11 @@ export class WsClient {
|
||||
this.backoffMs = BACKOFF_MIN_MS;
|
||||
this.status.value = 'open';
|
||||
this.sendSub();
|
||||
// P7 — ré-arme les watchers de worktree (clé repoId\0path) après reconnexion.
|
||||
for (const key of this.worktreeChangesListeners.keys()) {
|
||||
const sep = key.indexOf('\0');
|
||||
this.sendControl({ type: 'watch', repoId: key.slice(0, sep), path: key.slice(sep + 1) });
|
||||
}
|
||||
// ré-attache des terminaux encore ouverts (nouveaux canaux côté serveur)
|
||||
for (const att of this.attachments) {
|
||||
if (!att.closed) this.sendAttach(att);
|
||||
@@ -451,6 +486,11 @@ export class WsClient {
|
||||
for (const cb of this.groupListeners) cb(msg);
|
||||
return;
|
||||
}
|
||||
case 'worktree_changes': {
|
||||
const set = this.worktreeChangesListeners.get(`${msg.repoId}\0${msg.path}`);
|
||||
if (set) for (const cb of set) cb(msg);
|
||||
return;
|
||||
}
|
||||
case 'error': {
|
||||
if (msg.channel !== undefined) {
|
||||
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
||||
|
||||
Reference in New Issue
Block a user