@mrpatronz/nexusflow 0.2.18 → 0.2.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +37 -50
- package/dist/commands/sync.js.map +1 -1
- package/dist/core/sync.d.ts +44 -0
- package/dist/core/sync.d.ts.map +1 -0
- package/dist/core/sync.js +82 -0
- package/dist/core/sync.js.map +1 -0
- package/dist/core/workspace-state.d.ts +51 -0
- package/dist/core/workspace-state.d.ts.map +1 -0
- package/dist/core/workspace-state.js +108 -0
- package/dist/core/workspace-state.js.map +1 -0
- package/dist/core/workspace-state.test.d.ts +2 -0
- package/dist/core/workspace-state.test.d.ts.map +1 -0
- package/dist/core/workspace-state.test.js +84 -0
- package/dist/core/workspace-state.test.js.map +1 -0
- package/dist/gui/assets/index-Bdbld6yY.css +1 -0
- package/dist/gui/assets/index-D8iJPvnk.js +26 -0
- package/dist/gui/index.html +2 -2
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +42 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +94 -13
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/multi-git.d.ts +33 -8
- package/dist/utils/multi-git.d.ts.map +1 -1
- package/dist/utils/multi-git.js +91 -19
- package/dist/utils/multi-git.js.map +1 -1
- package/dist/utils/multi-git.test.d.ts +2 -0
- package/dist/utils/multi-git.test.d.ts.map +1 -0
- package/dist/utils/multi-git.test.js +125 -0
- package/dist/utils/multi-git.test.js.map +1 -0
- package/gui/e2e/dashboard.spec.ts +61 -0
- package/gui/e2e/wizard.spec.ts +2 -2
- package/gui/package-lock.json +62 -4
- package/gui/package.json +2 -1
- package/gui/src/App.tsx +168 -227
- package/gui/src/app/AppSidebar.tsx +69 -0
- package/gui/src/components/AddRepoPicker.tsx +81 -0
- package/gui/src/components/ui/Button.tsx +40 -0
- package/gui/src/components/ui/Card.tsx +6 -0
- package/gui/src/components/ui/EmptyState.tsx +22 -0
- package/gui/src/components/ui/Input.tsx +21 -0
- package/gui/src/components/ui/Menu.tsx +71 -0
- package/gui/src/components/ui/Modal.tsx +39 -0
- package/gui/src/components/ui/PageHeader.tsx +21 -0
- package/gui/src/components/ui/RepoStatusStrip.tsx +33 -0
- package/gui/src/components/ui/Skeleton.tsx +5 -0
- package/gui/src/components/ui/StatusPill.tsx +36 -0
- package/gui/src/components/ui/Tabs.tsx +40 -0
- package/gui/src/components/ui/cn.ts +4 -0
- package/gui/src/components/ui/index.ts +16 -0
- package/gui/src/features/services/ServiceConsole.tsx +4 -39
- package/gui/src/index.css +140 -125
- package/gui/src/lib/status.ts +24 -0
- package/gui/src/pages/DashboardPage.tsx +129 -0
- package/gui/src/pages/WorkspacesPage.tsx +352 -0
- package/gui/src/types.ts +19 -0
- package/package.json +1 -1
- package/src/commands/sync.ts +36 -58
- package/src/core/sync.ts +121 -0
- package/src/core/workspace-state.test.ts +108 -0
- package/src/core/workspace-state.ts +130 -0
- package/src/mcp/server.ts +44 -0
- package/src/server.ts +103 -15
- package/src/types.ts +36 -0
- package/src/utils/multi-git.test.ts +158 -0
- package/src/utils/multi-git.ts +117 -25
- package/dist/gui/assets/index-CB-jWded.css +0 -1
- package/dist/gui/assets/index-DDXpZZu3.js +0 -25
- package/gui/src/features/workspace/WorkspaceList.tsx +0 -666
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { useMemo, useState, type ComponentProps } from 'react';
|
|
2
|
+
import { RefreshCw, Play, MoreVertical, ExternalLink, Sparkles, Trash2, Search, FolderGit2 } from 'lucide-react';
|
|
3
|
+
import type { Feature, WorkspaceStatus, RepoInfo } from '../types.js';
|
|
4
|
+
import { Button, Card, EmptyState, Menu, PageHeader, StatusPill, Tabs, Input, Skeleton, cn } from '../components/ui/index.js';
|
|
5
|
+
import type { TabItem } from '../components/ui/index.js';
|
|
6
|
+
import { AddRepoPicker } from '../components/AddRepoPicker.js';
|
|
7
|
+
import { syncMeta, repoName } from '../lib/status.js';
|
|
8
|
+
import { SessionHistory } from '../features/sessions/SessionHistory.js';
|
|
9
|
+
import { ServiceConsole } from '../features/services/ServiceConsole.js';
|
|
10
|
+
import { ChangesViewer } from '../features/changes/ChangesViewer.js';
|
|
11
|
+
import { KnowledgeBase } from '../features/knowledge/KnowledgeBase.js';
|
|
12
|
+
import { ImplementationPlan } from '../features/plan/ImplementationPlan.js';
|
|
13
|
+
|
|
14
|
+
type SubTab = 'overview' | 'sessions' | 'services' | 'changes' | 'knowledge' | 'plan';
|
|
15
|
+
|
|
16
|
+
const TABS: TabItem[] = [
|
|
17
|
+
{ value: 'overview', label: 'Overview' },
|
|
18
|
+
{ value: 'changes', label: 'Changes' },
|
|
19
|
+
{ value: 'services', label: 'Services' },
|
|
20
|
+
{ value: 'sessions', label: 'Sessions' },
|
|
21
|
+
{ value: 'knowledge', label: 'Knowledge' },
|
|
22
|
+
{ value: 'plan', label: 'Plan' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const FILTERS = ['all', 'changes', 'running'] as const;
|
|
26
|
+
type Filter = (typeof FILTERS)[number];
|
|
27
|
+
|
|
28
|
+
interface WorkspacesPageProps {
|
|
29
|
+
workspaces: Feature[];
|
|
30
|
+
workspaceStatuses: Record<string, WorkspaceStatus>;
|
|
31
|
+
statusesLoading: boolean;
|
|
32
|
+
workspacesLoading: boolean;
|
|
33
|
+
fetchWorkspaces: () => Promise<void>;
|
|
34
|
+
selectedId: string | null;
|
|
35
|
+
subTab: SubTab;
|
|
36
|
+
onSelect: (id: string) => void;
|
|
37
|
+
onSelectTab: (id: string, tab: SubTab) => void;
|
|
38
|
+
resumingWs: string | null;
|
|
39
|
+
handleResumeSession: (ws: Feature, sessionId?: string, assistant?: string) => Promise<void>;
|
|
40
|
+
handleCopyPrompt: (ws: Feature) => void;
|
|
41
|
+
handleOpenInEditor: (workspacePath: string) => Promise<void>;
|
|
42
|
+
handleDeleteWorkspace: (wsName: string) => Promise<void>;
|
|
43
|
+
deleteWsLoading: string | null;
|
|
44
|
+
repos: RepoInfo[];
|
|
45
|
+
addRepoLoading: boolean;
|
|
46
|
+
handleAddRepo: (wsName: string, repoPath: string) => Promise<void>;
|
|
47
|
+
sessionProps: Omit<ComponentProps<typeof SessionHistory>, 'ws'>;
|
|
48
|
+
serviceProps: Omit<ComponentProps<typeof ServiceConsole>, 'ws'>;
|
|
49
|
+
changesProps: Omit<ComponentProps<typeof ChangesViewer>, 'ws'>;
|
|
50
|
+
knowledgeProps: Omit<ComponentProps<typeof KnowledgeBase>, 'ws'>;
|
|
51
|
+
planProps: ComponentProps<typeof ImplementationPlan>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function WorkspacesPage(props: WorkspacesPageProps) {
|
|
55
|
+
const {
|
|
56
|
+
workspaces,
|
|
57
|
+
workspaceStatuses,
|
|
58
|
+
workspacesLoading,
|
|
59
|
+
fetchWorkspaces,
|
|
60
|
+
selectedId,
|
|
61
|
+
subTab,
|
|
62
|
+
onSelect,
|
|
63
|
+
onSelectTab,
|
|
64
|
+
resumingWs,
|
|
65
|
+
handleResumeSession,
|
|
66
|
+
handleCopyPrompt,
|
|
67
|
+
handleOpenInEditor,
|
|
68
|
+
handleDeleteWorkspace,
|
|
69
|
+
deleteWsLoading,
|
|
70
|
+
repos,
|
|
71
|
+
addRepoLoading,
|
|
72
|
+
handleAddRepo,
|
|
73
|
+
sessionProps,
|
|
74
|
+
serviceProps,
|
|
75
|
+
changesProps,
|
|
76
|
+
knowledgeProps,
|
|
77
|
+
planProps,
|
|
78
|
+
} = props;
|
|
79
|
+
|
|
80
|
+
const [query, setQuery] = useState('');
|
|
81
|
+
const [filter, setFilter] = useState<Filter>('all');
|
|
82
|
+
|
|
83
|
+
const selected = workspaces.find((w) => w.branchName === selectedId) ?? null;
|
|
84
|
+
|
|
85
|
+
const filtered = useMemo(() => {
|
|
86
|
+
const q = query.trim().toLowerCase();
|
|
87
|
+
return workspaces.filter((w) => {
|
|
88
|
+
const st = workspaceStatuses[w.branchName];
|
|
89
|
+
if (q && !`${w.branchName} ${w.description}`.toLowerCase().includes(q)) return false;
|
|
90
|
+
if (filter === 'changes' && !(st && st.changedFiles > 0)) return false;
|
|
91
|
+
if (filter === 'running' && !(st && st.runningServices > 0)) return false;
|
|
92
|
+
return true;
|
|
93
|
+
});
|
|
94
|
+
}, [workspaces, workspaceStatuses, query, filter]);
|
|
95
|
+
|
|
96
|
+
const repoRows = selected
|
|
97
|
+
? selected.repos.map((rp) => {
|
|
98
|
+
const name = repoName(rp);
|
|
99
|
+
const change = changesProps.gitChanges?.find((c: { repoName: string; files?: unknown[] }) => c.repoName === name);
|
|
100
|
+
const changedCount: number | null = change ? change.files?.length ?? 0 : null;
|
|
101
|
+
const svcs = (serviceProps.services ?? []).filter((s) => (s.cwd ?? '').split(/[\\/]/).includes(name));
|
|
102
|
+
const ports = Array.from(new Set(svcs.map((s) => s.port).filter((p): p is number => typeof p === 'number')));
|
|
103
|
+
return { name, changedCount, ports, serviceCount: svcs.length };
|
|
104
|
+
})
|
|
105
|
+
: [];
|
|
106
|
+
|
|
107
|
+
const availableRepos = selected ? repos.filter((r) => !selected.repos.includes(r.path)) : [];
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className="mx-auto max-w-7xl animate-fade-in">
|
|
111
|
+
<PageHeader
|
|
112
|
+
title="Workspaces"
|
|
113
|
+
subtitle="Select a workspace to inspect its changes, services, sessions and context."
|
|
114
|
+
actions={
|
|
115
|
+
<Button
|
|
116
|
+
variant="secondary"
|
|
117
|
+
icon={<RefreshCw size={14} className={workspacesLoading ? 'animate-spin text-accent' : ''} />}
|
|
118
|
+
onClick={fetchWorkspaces}
|
|
119
|
+
disabled={workspacesLoading}
|
|
120
|
+
>
|
|
121
|
+
Refresh
|
|
122
|
+
</Button>
|
|
123
|
+
}
|
|
124
|
+
/>
|
|
125
|
+
|
|
126
|
+
<div className="flex flex-col gap-5 lg:flex-row">
|
|
127
|
+
{/* ── List pane ─────────────────────────────────────────── */}
|
|
128
|
+
<div className="lg:w-80 lg:shrink-0">
|
|
129
|
+
<div className="relative mb-3">
|
|
130
|
+
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
|
|
131
|
+
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search workspaces" className="pl-8" />
|
|
132
|
+
</div>
|
|
133
|
+
<div className="mb-3 flex items-center gap-1">
|
|
134
|
+
{FILTERS.map((f) => (
|
|
135
|
+
<button
|
|
136
|
+
key={f}
|
|
137
|
+
onClick={() => setFilter(f)}
|
|
138
|
+
className={cn(
|
|
139
|
+
'rounded-md px-2.5 py-1 text-xs font-medium capitalize transition-colors cursor-pointer',
|
|
140
|
+
filter === f ? 'bg-accent-soft text-accent' : 'text-content-faint hover:text-content',
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
{f}
|
|
144
|
+
</button>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{workspacesLoading ? (
|
|
149
|
+
<div className="flex flex-col gap-1.5">
|
|
150
|
+
{[0, 1, 2, 3].map((i) => (
|
|
151
|
+
<Skeleton key={i} className="h-14" />
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
) : filtered.length === 0 ? (
|
|
155
|
+
<Card className="p-6 text-center text-sm text-content-muted">No workspaces match.</Card>
|
|
156
|
+
) : (
|
|
157
|
+
<div className="flex flex-col gap-1.5">
|
|
158
|
+
{filtered.map((w) => {
|
|
159
|
+
const st = workspaceStatuses[w.branchName];
|
|
160
|
+
const active = w.branchName === selectedId;
|
|
161
|
+
const sync = st ? syncMeta(st.syncStatus) : null;
|
|
162
|
+
return (
|
|
163
|
+
<button
|
|
164
|
+
key={w.id}
|
|
165
|
+
onClick={() => onSelect(w.branchName)}
|
|
166
|
+
className={cn(
|
|
167
|
+
'rounded-lg border p-3 text-left transition-colors cursor-pointer',
|
|
168
|
+
active ? 'border-accent/50 bg-accent-soft' : 'border-hairline bg-surface hover:border-hairline-strong hover:bg-raised',
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
<div className="flex items-center justify-between gap-2">
|
|
172
|
+
<span className="truncate font-mono text-sm font-semibold text-content">{w.branchName}</span>
|
|
173
|
+
<span className="shrink-0 text-[11px] text-content-faint">{w.repos.length} repos</span>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="mt-1.5 flex items-center gap-2">
|
|
176
|
+
<span
|
|
177
|
+
className={cn('h-1.5 w-1.5 rounded-full', st?.changedFiles ? 'bg-warning' : 'bg-success')}
|
|
178
|
+
title={st?.changedFiles ? `${st.changedFiles} uncommitted` : 'Clean'}
|
|
179
|
+
/>
|
|
180
|
+
{st?.runningServices ? <span className="h-1.5 w-1.5 rounded-full bg-running" title="Running services" /> : null}
|
|
181
|
+
{sync && <span className="text-[11px] text-content-faint">{sync.label}</span>}
|
|
182
|
+
</div>
|
|
183
|
+
</button>
|
|
184
|
+
);
|
|
185
|
+
})}
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{/* ── Detail pane ───────────────────────────────────────── */}
|
|
191
|
+
<div className="min-w-0 flex-1">
|
|
192
|
+
{!selected ? (
|
|
193
|
+
<Card>
|
|
194
|
+
<EmptyState
|
|
195
|
+
icon={<FolderGit2 size={40} />}
|
|
196
|
+
title="No workspace selected"
|
|
197
|
+
description="Pick a workspace from the list to see its status, repositories, changes, services and sessions."
|
|
198
|
+
/>
|
|
199
|
+
</Card>
|
|
200
|
+
) : (
|
|
201
|
+
<div>
|
|
202
|
+
<Card className="mb-4 p-5">
|
|
203
|
+
<div className="flex items-start justify-between gap-4">
|
|
204
|
+
<div className="min-w-0">
|
|
205
|
+
<h2 className="font-display text-xl font-bold text-content">{selected.branchName}</h2>
|
|
206
|
+
<div className="mt-1 flex items-center gap-2 text-xs text-content-faint">
|
|
207
|
+
<span>Created {new Date(selected.createdAt).toLocaleDateString()}</span>
|
|
208
|
+
<span>·</span>
|
|
209
|
+
<span>{selected.repos.length} repos</span>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
213
|
+
<Button
|
|
214
|
+
variant="primary"
|
|
215
|
+
icon={<Play size={13} className={resumingWs === selected.branchName ? 'animate-spin' : ''} />}
|
|
216
|
+
disabled={resumingWs === selected.branchName}
|
|
217
|
+
onClick={() => handleResumeSession(selected)}
|
|
218
|
+
>
|
|
219
|
+
{resumingWs === selected.branchName ? 'Resuming…' : 'Resume in Editor'}
|
|
220
|
+
</Button>
|
|
221
|
+
<Menu
|
|
222
|
+
label="More actions"
|
|
223
|
+
trigger={
|
|
224
|
+
<span className="grid h-9 w-9 place-items-center rounded-md border border-hairline bg-surface text-content-muted hover:bg-raised hover:text-content">
|
|
225
|
+
<MoreVertical size={16} />
|
|
226
|
+
</span>
|
|
227
|
+
}
|
|
228
|
+
items={[
|
|
229
|
+
{ label: 'Open Folder', icon: <ExternalLink size={14} />, onClick: () => void handleOpenInEditor(selected.workspacePath) },
|
|
230
|
+
{ label: 'Copy AI Context', icon: <Sparkles size={14} className="text-accent" />, onClick: () => handleCopyPrompt(selected) },
|
|
231
|
+
{
|
|
232
|
+
label: deleteWsLoading === selected.branchName ? 'Deleting…' : 'Delete Workspace',
|
|
233
|
+
icon: <Trash2 size={14} />,
|
|
234
|
+
danger: true,
|
|
235
|
+
disabled: deleteWsLoading === selected.branchName,
|
|
236
|
+
onClick: () => void handleDeleteWorkspace(selected.branchName),
|
|
237
|
+
},
|
|
238
|
+
]}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
244
|
+
{(() => {
|
|
245
|
+
const st = workspaceStatuses[selected.branchName];
|
|
246
|
+
if (!st) return null;
|
|
247
|
+
const sync = syncMeta(st.syncStatus);
|
|
248
|
+
return (
|
|
249
|
+
<>
|
|
250
|
+
{st.changedFiles > 0 ? (
|
|
251
|
+
<StatusPill tone="warning" dot>
|
|
252
|
+
{st.changedFiles} uncommitted
|
|
253
|
+
</StatusPill>
|
|
254
|
+
) : (
|
|
255
|
+
<StatusPill tone="idle" dot>
|
|
256
|
+
Clean
|
|
257
|
+
</StatusPill>
|
|
258
|
+
)}
|
|
259
|
+
{st.runningServices > 0 ? (
|
|
260
|
+
<StatusPill tone="running" dot>
|
|
261
|
+
{st.runningServices} running
|
|
262
|
+
</StatusPill>
|
|
263
|
+
) : (
|
|
264
|
+
<StatusPill tone="idle" dot>
|
|
265
|
+
No services
|
|
266
|
+
</StatusPill>
|
|
267
|
+
)}
|
|
268
|
+
<StatusPill tone={sync.tone}>
|
|
269
|
+
<RefreshCw size={11} /> {sync.label}
|
|
270
|
+
</StatusPill>
|
|
271
|
+
{st.pendingValidation && <StatusPill tone="warning">Needs validation</StatusPill>}
|
|
272
|
+
</>
|
|
273
|
+
);
|
|
274
|
+
})()}
|
|
275
|
+
</div>
|
|
276
|
+
</Card>
|
|
277
|
+
|
|
278
|
+
<Tabs items={TABS} value={subTab} onChange={(v) => onSelectTab(selected.branchName, v as SubTab)} className="mb-4" />
|
|
279
|
+
|
|
280
|
+
<div className="animate-fade-in">
|
|
281
|
+
{subTab === 'overview' && (
|
|
282
|
+
<div className="flex flex-col gap-4">
|
|
283
|
+
<Card className="p-5">
|
|
284
|
+
<h3 className="mb-2 font-display text-sm font-semibold text-content">Description</h3>
|
|
285
|
+
{selected.description ? (
|
|
286
|
+
<p className="whitespace-pre-line text-sm leading-relaxed text-content-muted">{selected.description}</p>
|
|
287
|
+
) : (
|
|
288
|
+
<p className="text-sm text-content-faint">No description provided.</p>
|
|
289
|
+
)}
|
|
290
|
+
</Card>
|
|
291
|
+
<Card className="p-5">
|
|
292
|
+
<div className="mb-4 flex items-center justify-between gap-3">
|
|
293
|
+
<h3 className="font-display text-sm font-semibold text-content">
|
|
294
|
+
Repositories <span className="text-content-faint">({selected.repos.length})</span>
|
|
295
|
+
</h3>
|
|
296
|
+
{availableRepos.length > 0 && (
|
|
297
|
+
<AddRepoPicker
|
|
298
|
+
repos={availableRepos}
|
|
299
|
+
disabled={addRepoLoading}
|
|
300
|
+
onAdd={(path) => {
|
|
301
|
+
if (
|
|
302
|
+
window.confirm(
|
|
303
|
+
`Add repository "${repoName(path)}" to this workspace?\nThis creates a new git worktree and re-runs analysis.`,
|
|
304
|
+
)
|
|
305
|
+
) {
|
|
306
|
+
void handleAddRepo(selected.branchName, path);
|
|
307
|
+
}
|
|
308
|
+
}}
|
|
309
|
+
/>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
<div className="divide-y divide-hairline">
|
|
313
|
+
{repoRows.map((r) => (
|
|
314
|
+
<div key={r.name} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
|
|
315
|
+
<span
|
|
316
|
+
className={cn(
|
|
317
|
+
'h-2 w-2 shrink-0 rounded-full',
|
|
318
|
+
r.changedCount === null ? 'bg-idle' : r.changedCount > 0 ? 'bg-warning' : 'bg-success',
|
|
319
|
+
)}
|
|
320
|
+
title={r.changedCount === null ? 'Status unknown' : r.changedCount > 0 ? 'Uncommitted changes' : 'Clean'}
|
|
321
|
+
/>
|
|
322
|
+
<span className="min-w-0 flex-1 truncate font-mono text-sm text-content">{r.name}</span>
|
|
323
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
324
|
+
{r.ports.map((p) => (
|
|
325
|
+
<span key={p} className="rounded border border-hairline bg-base px-1.5 py-0.5 font-mono text-[11px] text-running">
|
|
326
|
+
:{p}
|
|
327
|
+
</span>
|
|
328
|
+
))}
|
|
329
|
+
<span className="w-24 text-right text-xs text-content-faint">
|
|
330
|
+
{r.changedCount === null ? '—' : r.changedCount > 0 ? `${r.changedCount} changed` : 'clean'}
|
|
331
|
+
</span>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
))}
|
|
335
|
+
</div>
|
|
336
|
+
<p className="mt-3 text-[11px] text-content-faint">Per-repo git state and detected service ports.</p>
|
|
337
|
+
</Card>
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
{subTab === 'sessions' && <SessionHistory ws={selected} {...sessionProps} />}
|
|
341
|
+
{subTab === 'services' && <ServiceConsole ws={selected} {...serviceProps} />}
|
|
342
|
+
{subTab === 'changes' && <ChangesViewer ws={selected} {...changesProps} />}
|
|
343
|
+
{subTab === 'knowledge' && <KnowledgeBase ws={selected} {...knowledgeProps} />}
|
|
344
|
+
{subTab === 'plan' && <ImplementationPlan {...planProps} />}
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
);
|
|
352
|
+
}
|
package/gui/src/types.ts
CHANGED
|
@@ -38,6 +38,25 @@ export interface Feature {
|
|
|
38
38
|
createdAt: string;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Classified outcome of a sync/rebase attempt for a repo (mirrors src/types.ts). */
|
|
42
|
+
export type SyncStatus = 'up-to-date' | 'rebased' | 'conflict' | 'stash-conflict' | 'error';
|
|
43
|
+
|
|
44
|
+
/** At-a-glance status for one workspace, from GET /api/workspaces/status. */
|
|
45
|
+
export interface WorkspaceStatus {
|
|
46
|
+
id: string;
|
|
47
|
+
branchName: string;
|
|
48
|
+
/** Total uncommitted files across all repo worktrees. */
|
|
49
|
+
changedFiles: number;
|
|
50
|
+
/** Number of repos with uncommitted changes. */
|
|
51
|
+
dirtyRepos: number;
|
|
52
|
+
/** Number of currently running orchestrated services. */
|
|
53
|
+
runningServices: number;
|
|
54
|
+
/** Worst-case sync classification across repos, or 'unknown' if never synced. */
|
|
55
|
+
syncStatus: SyncStatus | 'unknown';
|
|
56
|
+
/** True when any repo pulled in new commits and awaits re-validation. */
|
|
57
|
+
pendingValidation: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
export interface ServiceConfig {
|
|
42
61
|
name: string;
|
|
43
62
|
cwd: string;
|
package/package.json
CHANGED
package/src/commands/sync.ts
CHANGED
|
@@ -4,16 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import chalk from 'chalk';
|
|
7
|
-
import {
|
|
7
|
+
import { search } from '@inquirer/prompts';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import * as fs from 'node:fs/promises';
|
|
10
10
|
|
|
11
11
|
import { loadConfig } from '../core/config.js';
|
|
12
12
|
import { listWorkspaces, loadFeatureConfig } from '../core/workspace.js';
|
|
13
|
-
import {
|
|
14
|
-
import { analyzeAllRepos } from '../analyzers/index.js';
|
|
15
|
-
import { generateContextFiles } from '../generators/index.js';
|
|
16
|
-
import type { WorkspaceContext } from '../types.js';
|
|
13
|
+
import { syncWorkspace, type RepoSyncReport } from '../core/sync.js';
|
|
17
14
|
|
|
18
15
|
/**
|
|
19
16
|
* Executes the sync command.
|
|
@@ -35,69 +32,50 @@ export async function syncCommand(workspaceArg?: string): Promise<void> {
|
|
|
35
32
|
console.log(chalk.bold(`Syncing workspace: ${chalk.cyan(feature.branchName)}`));
|
|
36
33
|
console.log(chalk.dim(`Path: ${workspacePath}\n`));
|
|
37
34
|
|
|
38
|
-
let
|
|
35
|
+
let report;
|
|
39
36
|
try {
|
|
40
|
-
|
|
37
|
+
report = await syncWorkspace(workspacePath);
|
|
41
38
|
} catch (error) {
|
|
42
|
-
console.error(chalk.red(`✖ Failed to
|
|
39
|
+
console.error(chalk.red(`✖ Failed to sync: ${error instanceof Error ? error.message : String(error)}`));
|
|
43
40
|
return;
|
|
44
41
|
}
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
let conflictCount = 0;
|
|
48
|
-
|
|
49
|
-
for (const repo of repos) {
|
|
43
|
+
for (const repo of report.repos) {
|
|
50
44
|
console.log(`Repository: ${chalk.bold(repo.name)}`);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const spinner = chalk.dim(' Rebasing...');
|
|
54
|
-
process.stdout.write(spinner);
|
|
55
|
-
|
|
56
|
-
const result = await rebaseRepo(repo.path, defaultBranch);
|
|
57
|
-
|
|
58
|
-
// Clear rebase message line
|
|
59
|
-
process.stdout.write('\r' + ' '.repeat(spinner.length) + '\r');
|
|
60
|
-
|
|
61
|
-
if (result.success) {
|
|
62
|
-
console.log(` ${chalk.green('✅')} Synced (${result.message})`);
|
|
63
|
-
syncedCount++;
|
|
64
|
-
} else {
|
|
65
|
-
console.log(` ${chalk.red('⚠️')} Conflict: ${result.message}`);
|
|
66
|
-
if (result.conflict) {
|
|
67
|
-
console.log(chalk.dim(result.conflict.split('\n').map(l => ` ${l}`).slice(0, 5).join('\n')));
|
|
68
|
-
}
|
|
69
|
-
conflictCount++;
|
|
70
|
-
}
|
|
45
|
+
renderRepoResult(repo);
|
|
71
46
|
}
|
|
72
47
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
const allRepos = await Promise.all(feature.repos.map(r => {
|
|
79
|
-
const repoName = path.basename(r);
|
|
80
|
-
return {
|
|
81
|
-
name: repoName,
|
|
82
|
-
path: r,
|
|
83
|
-
defaultBranch: 'main',
|
|
84
|
-
};
|
|
85
|
-
}));
|
|
86
|
-
|
|
87
|
-
const analysis = await analyzeAllRepos(allRepos);
|
|
88
|
-
const ctx: WorkspaceContext = {
|
|
89
|
-
feature,
|
|
90
|
-
repos: allRepos,
|
|
91
|
-
analysis,
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
await generateContextFiles(ctx, feature.assistants, workspacePath);
|
|
48
|
+
const parts = [`${report.syncedCount} synced`];
|
|
49
|
+
if (report.conflictCount > 0) parts.push(`${report.conflictCount} conflict(s)`);
|
|
50
|
+
if (report.errorCount > 0) parts.push(`${report.errorCount} error(s)`);
|
|
51
|
+
console.log(`\n📊 ${chalk.bold('Summary:')} ${parts.join(', ')}\n`);
|
|
95
52
|
|
|
53
|
+
if (report.syncedCount > 0) {
|
|
54
|
+
console.log(chalk.green('✅ Workspace maps and contexts updated.\n'));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
96
57
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Prints a single repo's sync outcome with status-appropriate styling.
|
|
60
|
+
*/
|
|
61
|
+
function renderRepoResult(repo: RepoSyncReport): void {
|
|
62
|
+
switch (repo.status) {
|
|
63
|
+
case 'up-to-date':
|
|
64
|
+
case 'rebased':
|
|
65
|
+
console.log(` ${chalk.green('✅')} Synced (${repo.message})`);
|
|
66
|
+
break;
|
|
67
|
+
case 'stash-conflict':
|
|
68
|
+
console.log(` ${chalk.yellow('⚠️')} ${repo.message}`);
|
|
69
|
+
break;
|
|
70
|
+
case 'conflict':
|
|
71
|
+
console.log(` ${chalk.red('❌')} Conflict: ${repo.message}`);
|
|
72
|
+
if (repo.conflict) {
|
|
73
|
+
console.log(chalk.dim(repo.conflict.split('\n').map(l => ` ${l}`).slice(0, 5).join('\n')));
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
case 'error':
|
|
77
|
+
console.log(` ${chalk.red('🔌')} ${repo.message}`);
|
|
78
|
+
break;
|
|
101
79
|
}
|
|
102
80
|
}
|
|
103
81
|
|
package/src/core/sync.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/sync
|
|
3
|
+
* Headless workspace sync — rebases every repo in a workspace onto its base
|
|
4
|
+
* branch, records the per-repo outcome to workspace state, and regenerates
|
|
5
|
+
* context files. Produces no console output, so it can back the CLI, the HTTP
|
|
6
|
+
* API, and the MCP tool alike.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { loadFeatureConfig } from './workspace.js';
|
|
12
|
+
import { recordRepoSync } from './workspace-state.js';
|
|
13
|
+
import { getWorkspaceRepos, rebaseRepo } from '../utils/multi-git.js';
|
|
14
|
+
import { analyzeAllRepos } from '../analyzers/index.js';
|
|
15
|
+
import { generateContextFiles } from '../generators/index.js';
|
|
16
|
+
import type { SyncStatus, WorkspaceContext } from '../types.js';
|
|
17
|
+
|
|
18
|
+
/** Sync outcome for a single repo. */
|
|
19
|
+
export interface RepoSyncReport {
|
|
20
|
+
/** Directory name of the repo. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Classified outcome. */
|
|
23
|
+
status: SyncStatus;
|
|
24
|
+
/** Human-readable message. */
|
|
25
|
+
message: string;
|
|
26
|
+
/** Conflict stderr — populated only for `status === 'conflict'`. */
|
|
27
|
+
conflict?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Aggregated result of syncing an entire workspace. */
|
|
31
|
+
export interface SyncReport {
|
|
32
|
+
/** Absolute path to the workspace. */
|
|
33
|
+
workspacePath: string;
|
|
34
|
+
/** Feature branch name being synced. */
|
|
35
|
+
branchName: string;
|
|
36
|
+
/** Per-repo outcomes, in workspace order. */
|
|
37
|
+
repos: RepoSyncReport[];
|
|
38
|
+
/** Count of repos that landed cleanly (up-to-date, rebased, or stash-conflict). */
|
|
39
|
+
syncedCount: number;
|
|
40
|
+
/** Count of repos with a genuine merge conflict. */
|
|
41
|
+
conflictCount: number;
|
|
42
|
+
/** Count of repos that failed for infrastructure reasons (fetch/auth, etc.). */
|
|
43
|
+
errorCount: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A sync status counts as "synced" when the rebase itself landed. */
|
|
47
|
+
function isSynced(status: SyncStatus): boolean {
|
|
48
|
+
return status === 'up-to-date' || status === 'rebased' || status === 'stash-conflict';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Syncs all repos in a workspace. Throws if the workspace configuration cannot
|
|
53
|
+
* be loaded; individual repo failures are captured in the returned report
|
|
54
|
+
* rather than thrown.
|
|
55
|
+
*
|
|
56
|
+
* @param workspacePath - Absolute path to the workspace root.
|
|
57
|
+
* @returns A structured report of every repo's outcome.
|
|
58
|
+
*/
|
|
59
|
+
export async function syncWorkspace(workspacePath: string): Promise<SyncReport> {
|
|
60
|
+
const feature = await loadFeatureConfig(workspacePath);
|
|
61
|
+
if (!feature) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Failed to load workspace configuration. Ensure nexusflow.json exists at ${workspacePath}.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const repos = await getWorkspaceRepos(workspacePath);
|
|
68
|
+
|
|
69
|
+
const repoReports: RepoSyncReport[] = [];
|
|
70
|
+
let syncedCount = 0;
|
|
71
|
+
let conflictCount = 0;
|
|
72
|
+
let errorCount = 0;
|
|
73
|
+
|
|
74
|
+
for (const repo of repos) {
|
|
75
|
+
const defaultBranch = repo.defaultBranch || 'main';
|
|
76
|
+
const result = await rebaseRepo(repo.path, defaultBranch);
|
|
77
|
+
|
|
78
|
+
await recordRepoSync(workspacePath, repo.name, {
|
|
79
|
+
status: result.status,
|
|
80
|
+
message: result.message,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
repoReports.push({
|
|
84
|
+
name: repo.name,
|
|
85
|
+
status: result.status,
|
|
86
|
+
message: result.message,
|
|
87
|
+
conflict: result.conflict,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (isSynced(result.status)) syncedCount++;
|
|
91
|
+
else if (result.status === 'conflict') conflictCount++;
|
|
92
|
+
else errorCount++;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Regenerate maps/context when anything synced. A regen failure must never
|
|
96
|
+
// fail the sync itself — the rebases already happened.
|
|
97
|
+
if (syncedCount > 0) {
|
|
98
|
+
try {
|
|
99
|
+
const allRepos = feature.repos.map((r) => ({
|
|
100
|
+
name: path.basename(r),
|
|
101
|
+
path: r,
|
|
102
|
+
defaultBranch: 'main',
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
const analysis = await analyzeAllRepos(allRepos);
|
|
106
|
+
const ctx: WorkspaceContext = { feature, repos: allRepos, analysis };
|
|
107
|
+
await generateContextFiles(ctx, feature.assistants, workspacePath);
|
|
108
|
+
} catch {
|
|
109
|
+
// Best-effort regeneration; ignore failures.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
workspacePath,
|
|
115
|
+
branchName: feature.branchName,
|
|
116
|
+
repos: repoReports,
|
|
117
|
+
syncedCount,
|
|
118
|
+
conflictCount,
|
|
119
|
+
errorCount,
|
|
120
|
+
};
|
|
121
|
+
}
|