feat(tui): git view, project-scoped task stats, polished layout/theme
- Add a Git interactive view (hotkey [t]/[4]): branch + ahead/behind,
recent commits with detail strip, staged/unstaged/untracked files
panel, branches list, worktrees panel, and a [P] push modal with
pre-flight commit list and capture of stdout/stderr. ←→ cycles
status → branches → worktrees → commits → changes; ↑↓ navigates
rows in the focused list. 5s poll while mounted.
- Project-scoped task stats: BoardView pushes its selected project
path into the controller; refreshTUIStats now reads from a per-
project TaskStore via a shared getProjectStore helper. Project
switch triggers an immediate refresh via onBoardScopeChange.
- Unified MainHeader used by status and interactive modes — section
tabs ([1]–[5]) and interactive view tabs ([b]/[a]/[g]/[t]) always
visible; previously the interactive header replaced the main one.
- Logs panel keeps its size when expanding a single entry.
- 'f' cycles severity filter from any panel in status mode.
- BoardView: explicit cross-view shortcuts so g/a/t always switch
views regardless of input-handler ordering.
- BoardView column overlap fix: flexShrink={0} on structural rows.
- TaskCard always shows the title with wrap="wrap"; single short-id
pill, no duplicate id-as-title fallback.
- Stats panel reorg: StatRow helper, bold section headers, narrow-
width wrap for Heap and Memory trailing fragments.
- Lighter blue palette: cyanBright fg accents / cyan active bg /
logo gradient whiteBright → white → cyanBright → cyan → blue.
- System stats sampler: RSS, heap (V8 limit-aware color), external,
CPU%, load avg, system used/free. Heap thresholds scale off
--max-old-space-size automatically.
- Test fixture updated for the new InteractiveData.git block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/auth-token-recovery-dialog.md
Normal file
5
.changeset/auth-token-recovery-dialog.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a blocking dashboard token-recovery dialog that appears only for daemon bearer-token 401 responses, with set-token or clear-token recovery actions that reload the app.
|
||||||
@@ -63,6 +63,34 @@ function makeInteractiveData(opts: {
|
|||||||
getSettings: async () => settings,
|
getSettings: async () => settings,
|
||||||
updateSettings: async (_partial: Partial<SettingsValues>) => {},
|
updateSettings: async (_partial: Partial<SettingsValues>) => {},
|
||||||
listModels: () => models,
|
listModels: () => models,
|
||||||
|
git: {
|
||||||
|
getStatus: async () => ({
|
||||||
|
branch: "main",
|
||||||
|
detached: false,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
staged: [],
|
||||||
|
unstaged: [],
|
||||||
|
untracked: [],
|
||||||
|
remoteUrl: "",
|
||||||
|
lastFetchAt: null,
|
||||||
|
}),
|
||||||
|
listCommits: async () => [],
|
||||||
|
showCommit: async () => ({
|
||||||
|
sha: "",
|
||||||
|
shortSha: "",
|
||||||
|
subject: "",
|
||||||
|
authorName: "",
|
||||||
|
relativeTime: "",
|
||||||
|
isoTime: "",
|
||||||
|
body: "",
|
||||||
|
stat: "",
|
||||||
|
}),
|
||||||
|
listBranches: async () => [],
|
||||||
|
listWorktrees: async () => [],
|
||||||
|
push: async () => ({ success: true, output: "" }),
|
||||||
|
fetch: async () => ({ success: true, output: "" }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,10 @@ export class DashboardTUI {
|
|||||||
systemInfo: SystemInfo | null = null;
|
systemInfo: SystemInfo | null = null;
|
||||||
taskStats: TaskStats | null = null;
|
taskStats: TaskStats | null = null;
|
||||||
systemStats: SystemStats | null = null;
|
systemStats: SystemStats | null = null;
|
||||||
|
// When set, dashboard.ts refreshes task stats from this project path
|
||||||
|
// instead of the launch cwd. Mirrors BoardView's selected project.
|
||||||
|
boardScopedProjectPath: string | null = null;
|
||||||
|
private boardScopeListener: ((path: string | null) => void) | null = null;
|
||||||
settings: SettingsValues | null = null;
|
settings: SettingsValues | null = null;
|
||||||
callbacks: TUICallbacks | null = null;
|
callbacks: TUICallbacks | null = null;
|
||||||
isRunning = false;
|
isRunning = false;
|
||||||
@@ -132,6 +136,20 @@ export class DashboardTUI {
|
|||||||
this.notify();
|
this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBoardScopedProjectPath(path: string | null): void {
|
||||||
|
if (this.boardScopedProjectPath === path) return;
|
||||||
|
this.boardScopedProjectPath = path;
|
||||||
|
this.boardScopeListener?.(path);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
onBoardScopeChange(listener: (path: string | null) => void): () => void {
|
||||||
|
this.boardScopeListener = listener;
|
||||||
|
return () => {
|
||||||
|
if (this.boardScopeListener === listener) this.boardScopeListener = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Sample process memory + CPU% in-place. Called from the sampler timer. */
|
/** Sample process memory + CPU% in-place. Called from the sampler timer. */
|
||||||
sampleSystemStats(): void {
|
sampleSystemStats(): void {
|
||||||
const mem = process.memoryUsage();
|
const mem = process.memoryUsage();
|
||||||
|
|||||||
@@ -17,4 +17,9 @@ export type {
|
|||||||
InteractiveData,
|
InteractiveData,
|
||||||
ProjectItem,
|
ProjectItem,
|
||||||
TaskItem,
|
TaskItem,
|
||||||
|
GitStatus,
|
||||||
|
GitCommit,
|
||||||
|
GitCommitDetail,
|
||||||
|
GitBranch,
|
||||||
|
GitWorktree,
|
||||||
} from "./state.js";
|
} from "./state.js";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type SectionId = "logs" | "system" | "utilities" | "stats" | "settings";
|
|||||||
|
|
||||||
export type AppMode = "status" | "interactive";
|
export type AppMode = "status" | "interactive";
|
||||||
|
|
||||||
export type InteractiveView = "board" | "agents" | "settings";
|
export type InteractiveView = "board" | "agents" | "settings" | "git";
|
||||||
|
|
||||||
export interface SystemInfo {
|
export interface SystemInfo {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -124,6 +124,50 @@ export interface ModelItem {
|
|||||||
contextWindow: number;
|
contextWindow: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Git view types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GitStatus {
|
||||||
|
branch: string;
|
||||||
|
detached: boolean;
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
staged: Array<{ status: string; path: string }>;
|
||||||
|
unstaged: Array<{ status: string; path: string }>;
|
||||||
|
untracked: Array<{ path: string }>;
|
||||||
|
remoteUrl: string;
|
||||||
|
lastFetchAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitCommit {
|
||||||
|
sha: string;
|
||||||
|
shortSha: string;
|
||||||
|
subject: string;
|
||||||
|
authorName: string;
|
||||||
|
relativeTime: string;
|
||||||
|
isoTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitCommitDetail extends GitCommit {
|
||||||
|
body: string;
|
||||||
|
stat: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitBranch {
|
||||||
|
name: string;
|
||||||
|
shortSha: string;
|
||||||
|
relativeTime: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
upstreamTrack: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitWorktree {
|
||||||
|
path: string;
|
||||||
|
branch: string;
|
||||||
|
sha: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
isLocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InteractiveData {
|
export interface InteractiveData {
|
||||||
listProjects: () => Promise<ProjectItem[]>;
|
listProjects: () => Promise<ProjectItem[]>;
|
||||||
listTasks: (projectPath: string) => Promise<TaskItem[]>;
|
listTasks: (projectPath: string) => Promise<TaskItem[]>;
|
||||||
@@ -135,6 +179,15 @@ export interface InteractiveData {
|
|||||||
getSettings: () => Promise<SettingsValues>;
|
getSettings: () => Promise<SettingsValues>;
|
||||||
updateSettings: (partial: Partial<SettingsValues>) => Promise<void>;
|
updateSettings: (partial: Partial<SettingsValues>) => Promise<void>;
|
||||||
listModels: () => ModelItem[];
|
listModels: () => ModelItem[];
|
||||||
|
git: {
|
||||||
|
getStatus: (projectPath: string) => Promise<GitStatus>;
|
||||||
|
listCommits: (projectPath: string, limit?: number) => Promise<GitCommit[]>;
|
||||||
|
showCommit: (projectPath: string, sha: string) => Promise<GitCommitDetail>;
|
||||||
|
listBranches: (projectPath: string) => Promise<GitBranch[]>;
|
||||||
|
listWorktrees: (projectPath: string) => Promise<GitWorktree[]>;
|
||||||
|
push: (projectPath: string) => Promise<{ success: boolean; output: string }>;
|
||||||
|
fetch: (projectPath: string) => Promise<{ success: boolean; output: string }>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dashboard state (mutable, shared between controller and App) ───────────────
|
// ── Dashboard state (mutable, shared between controller and App) ───────────────
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { execFile as execFileCb } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
import {
|
import {
|
||||||
TaskStore,
|
TaskStore,
|
||||||
AutomationStore,
|
AutomationStore,
|
||||||
@@ -42,7 +45,7 @@ import {
|
|||||||
resolveClaudeCliExtensionPaths,
|
resolveClaudeCliExtensionPaths,
|
||||||
setCachedClaudeCliResolution,
|
setCachedClaudeCliResolution,
|
||||||
} from "./claude-cli-extension.js";
|
} from "./claude-cli-extension.js";
|
||||||
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo } from "./dashboard-tui/index.js";
|
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree } from "./dashboard-tui/index.js";
|
||||||
|
|
||||||
// Re-export for backward compatibility with tests
|
// Re-export for backward compatibility with tests
|
||||||
export { promptForPort };
|
export { promptForPort };
|
||||||
@@ -311,6 +314,188 @@ function setDiagnosticStoreListenerCheck(check: () => Record<string, number>): v
|
|||||||
diagnosticStoreListenerCheck = check;
|
diagnosticStoreListenerCheck = check;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFileCb);
|
||||||
|
|
||||||
|
async function gitExec(cwd: string, args: string[]): Promise<string> {
|
||||||
|
const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 4 * 1024 * 1024 });
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildGitStatus(projectPath: string): Promise<GitStatus> {
|
||||||
|
const [sbOut, remoteOut] = await Promise.allSettled([
|
||||||
|
gitExec(projectPath, ["status", "-sb", "--porcelain=v1"]),
|
||||||
|
gitExec(projectPath, ["remote", "get-url", "origin"]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sbRaw = sbOut.status === "fulfilled" ? sbOut.value : "";
|
||||||
|
const remoteUrl = remoteOut.status === "fulfilled" ? remoteOut.value.trim() : "";
|
||||||
|
|
||||||
|
const lines = sbRaw.split("\n");
|
||||||
|
const header = lines[0] ?? "";
|
||||||
|
|
||||||
|
let branch = "HEAD";
|
||||||
|
let detached = false;
|
||||||
|
let ahead = 0;
|
||||||
|
let behind = 0;
|
||||||
|
|
||||||
|
const noCommitMatch = header.match(/^## No commits yet on (.+)$/);
|
||||||
|
if (noCommitMatch) {
|
||||||
|
branch = noCommitMatch[1] ?? "HEAD";
|
||||||
|
} else {
|
||||||
|
const branchMatch = header.match(/^## ([^.]+?)(?:\.\.\.(\S+?)(?:\s+\[ahead (\d+)(?:, behind (\d+))?\]|\s+\[behind (\d+)\])?)?$/);
|
||||||
|
if (branchMatch) {
|
||||||
|
branch = branchMatch[1] ?? "HEAD";
|
||||||
|
ahead = parseInt(branchMatch[3] ?? "0", 10);
|
||||||
|
behind = parseInt(branchMatch[4] ?? branchMatch[5] ?? "0", 10);
|
||||||
|
} else if (header.startsWith("## HEAD (no branch)")) {
|
||||||
|
detached = true;
|
||||||
|
branch = "HEAD";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const staged: GitStatus["staged"] = [];
|
||||||
|
const unstaged: GitStatus["unstaged"] = [];
|
||||||
|
const untracked: GitStatus["untracked"] = [];
|
||||||
|
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
if (line.length < 3) continue;
|
||||||
|
const x = line[0] ?? " ";
|
||||||
|
const y = line[1] ?? " ";
|
||||||
|
const path = line.slice(3);
|
||||||
|
if (x === "?" && y === "?") {
|
||||||
|
untracked.push({ path });
|
||||||
|
} else {
|
||||||
|
if (x !== " " && x !== "?") staged.push({ status: x, path });
|
||||||
|
if (y !== " " && y !== "?") unstaged.push({ status: y, path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastFetchAt: number | null = null;
|
||||||
|
try {
|
||||||
|
const fetchHead = await stat(`${projectPath}/.git/FETCH_HEAD`);
|
||||||
|
lastFetchAt = fetchHead.mtimeMs;
|
||||||
|
} catch {
|
||||||
|
// no fetch head yet
|
||||||
|
}
|
||||||
|
|
||||||
|
return { branch, detached, ahead, behind, staged, unstaged, untracked, remoteUrl, lastFetchAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildGitCommits(projectPath: string, limit = 15): Promise<GitCommit[]> {
|
||||||
|
const sep = "\x1f";
|
||||||
|
const recSep = "\x1e";
|
||||||
|
const fmt = [`%H`, `%h`, `%s`, `%an`, `%ar`, `%aI`].join(sep);
|
||||||
|
let out = "";
|
||||||
|
try {
|
||||||
|
out = await gitExec(projectPath, ["log", `--max-count=${limit}`, `--format=${fmt}${recSep}`]);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return out.split(recSep).flatMap((rec) => {
|
||||||
|
const parts = rec.trim().split(sep);
|
||||||
|
if (parts.length < 6 || !parts[0]) return [];
|
||||||
|
return [{
|
||||||
|
sha: parts[0] ?? "",
|
||||||
|
shortSha: parts[1] ?? "",
|
||||||
|
subject: parts[2] ?? "",
|
||||||
|
authorName: parts[3] ?? "",
|
||||||
|
relativeTime: parts[4] ?? "",
|
||||||
|
isoTime: parts[5] ?? "",
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildGitCommitDetail(projectPath: string, sha: string): Promise<GitCommitDetail> {
|
||||||
|
const sep = "\x1f";
|
||||||
|
const fmt = [`%H`, `%h`, `%s`, `%an`, `%ar`, `%aI`, `%b`].join(sep);
|
||||||
|
const [showOut, statOut] = await Promise.allSettled([
|
||||||
|
gitExec(projectPath, ["show", `--format=${fmt}`, "--no-patch", sha]),
|
||||||
|
gitExec(projectPath, ["show", "--stat", "--format=", sha]),
|
||||||
|
]);
|
||||||
|
const raw = showOut.status === "fulfilled" ? showOut.value.trim() : "";
|
||||||
|
const parts = raw.split(sep);
|
||||||
|
return {
|
||||||
|
sha: parts[0] ?? sha,
|
||||||
|
shortSha: parts[1] ?? sha.slice(0, 7),
|
||||||
|
subject: parts[2] ?? "",
|
||||||
|
authorName: parts[3] ?? "",
|
||||||
|
relativeTime: parts[4] ?? "",
|
||||||
|
isoTime: parts[5] ?? "",
|
||||||
|
body: (parts[6] ?? "").trim(),
|
||||||
|
stat: statOut.status === "fulfilled" ? statOut.value.trim() : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildGitBranches(projectPath: string): Promise<GitBranch[]> {
|
||||||
|
let out = "";
|
||||||
|
try {
|
||||||
|
out = await gitExec(projectPath, [
|
||||||
|
"for-each-ref",
|
||||||
|
"--sort=-committerdate",
|
||||||
|
"refs/heads",
|
||||||
|
"--format=%(refname:short)|%(objectname:short)|%(committerdate:relative)|%(upstream:track)|%(HEAD)",
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return out.trim().split("\n").flatMap((line) => {
|
||||||
|
if (!line) return [];
|
||||||
|
const parts = line.split("|");
|
||||||
|
return [{
|
||||||
|
name: parts[0] ?? "",
|
||||||
|
shortSha: parts[1] ?? "",
|
||||||
|
relativeTime: parts[2] ?? "",
|
||||||
|
upstreamTrack: parts[3] ?? "",
|
||||||
|
isCurrent: (parts[4] ?? "") === "*",
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildGitWorktrees(projectPath: string): Promise<GitWorktree[]> {
|
||||||
|
let out = "";
|
||||||
|
try {
|
||||||
|
out = await gitExec(projectPath, ["worktree", "list", "--porcelain"]);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const worktrees: GitWorktree[] = [];
|
||||||
|
let current: Partial<GitWorktree> & { rawPath?: string } = {};
|
||||||
|
let isFirst = true;
|
||||||
|
for (const line of out.split("\n")) {
|
||||||
|
if (line.startsWith("worktree ")) {
|
||||||
|
if (current.rawPath) {
|
||||||
|
worktrees.push({
|
||||||
|
path: current.rawPath,
|
||||||
|
branch: current.branch ?? "HEAD",
|
||||||
|
sha: current.sha ?? "",
|
||||||
|
isCurrent: current.isCurrent ?? false,
|
||||||
|
isLocked: current.isLocked ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
current = { rawPath: line.slice(9), isCurrent: isFirst };
|
||||||
|
isFirst = false;
|
||||||
|
} else if (line.startsWith("HEAD ")) {
|
||||||
|
current.sha = line.slice(5);
|
||||||
|
} else if (line.startsWith("branch ")) {
|
||||||
|
current.branch = line.slice(7).replace("refs/heads/", "");
|
||||||
|
} else if (line === "locked") {
|
||||||
|
current.isLocked = true;
|
||||||
|
} else if (line.startsWith("locked ")) {
|
||||||
|
current.isLocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.rawPath) {
|
||||||
|
worktrees.push({
|
||||||
|
path: current.rawPath,
|
||||||
|
branch: current.branch ?? "HEAD",
|
||||||
|
sha: current.sha ?? "",
|
||||||
|
isCurrent: current.isCurrent ?? false,
|
||||||
|
isLocked: current.isLocked ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return worktrees;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveRuntimeProjectPath(): Promise<string> {
|
async function resolveRuntimeProjectPath(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return (await resolveProject(undefined)).projectPath;
|
return (await resolveProject(undefined)).projectPath;
|
||||||
@@ -511,8 +696,22 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
let tuiRefreshPending = false;
|
let tuiRefreshPending = false;
|
||||||
let tuiRefreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let tuiRefreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Per-project task stores for the BoardView's scoped stats. Shared with the
|
||||||
|
// interactiveData wiring below so we don't re-init SQLite on each refresh.
|
||||||
|
const projectStores = new Map<string, TaskStore>();
|
||||||
|
async function getProjectStore(projectPath: string): Promise<TaskStore> {
|
||||||
|
const cached = projectStores.get(projectPath);
|
||||||
|
if (cached) return cached;
|
||||||
|
const projectStore = projectPath === cwd ? store : new TaskStore(projectPath);
|
||||||
|
if (projectPath !== cwd) await projectStore.init();
|
||||||
|
projectStores.set(projectPath, projectStore);
|
||||||
|
return projectStore;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debounced refresh of TUI stats - batches rapid task updates
|
* Debounced refresh of TUI stats - batches rapid task updates.
|
||||||
|
* If the BoardView has a scoped project path set on the controller,
|
||||||
|
* read tasks from that project's store instead of the launch cwd.
|
||||||
*/
|
*/
|
||||||
async function refreshTUIStats(): Promise<void> {
|
async function refreshTUIStats(): Promise<void> {
|
||||||
if (!tui || !isTTY) return;
|
if (!tui || !isTTY) return;
|
||||||
@@ -523,7 +722,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
tuiRefreshPending = true;
|
tuiRefreshPending = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
const scopedPath = tui.boardScopedProjectPath;
|
||||||
|
const taskStore = scopedPath ? await getProjectStore(scopedPath) : store;
|
||||||
|
const tasks = await taskStore.listTasks({ slim: true, includeArchived: false });
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
counts.set(task.column, (counts.get(task.column) ?? 0) + 1);
|
counts.set(task.column, (counts.get(task.column) ?? 0) + 1);
|
||||||
@@ -585,6 +786,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
}, 500); // 500ms debounce
|
}, 500); // 500ms debounce
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh stats immediately when the BoardView changes its selected project
|
||||||
|
// (so the Stats panel reflects the new project without waiting for an event).
|
||||||
|
if (tui) {
|
||||||
|
tui.onBoardScopeChange(() => {
|
||||||
|
void refreshTUIStats();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const handlers: Array<{
|
const handlers: Array<{
|
||||||
target: NodeJS.EventEmitter;
|
target: NodeJS.EventEmitter;
|
||||||
event: string | symbol;
|
event: string | symbol;
|
||||||
@@ -1524,19 +1733,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
// are cached so repeated panel switches don't re-init SQLite.
|
// are cached so repeated panel switches don't re-init SQLite.
|
||||||
if (centralCoreForMesh) {
|
if (centralCoreForMesh) {
|
||||||
const centralCore = centralCoreForMesh;
|
const centralCore = centralCoreForMesh;
|
||||||
const projectStores = new Map<string, TaskStore>();
|
|
||||||
tui.setInteractiveData({
|
tui.setInteractiveData({
|
||||||
listProjects: async () => {
|
listProjects: async () => {
|
||||||
const projects = await centralCore.listProjects();
|
const projects = await centralCore.listProjects();
|
||||||
return projects.map((p) => ({ id: p.id, name: p.name, path: p.path }));
|
return projects.map((p) => ({ id: p.id, name: p.name, path: p.path }));
|
||||||
},
|
},
|
||||||
listTasks: async (projectPath: string) => {
|
listTasks: async (projectPath: string) => {
|
||||||
let projectStore = projectStores.get(projectPath);
|
const projectStore = await getProjectStore(projectPath);
|
||||||
if (!projectStore) {
|
|
||||||
projectStore = projectPath === cwd ? store : new TaskStore(projectPath);
|
|
||||||
if (projectPath !== cwd) await projectStore.init();
|
|
||||||
projectStores.set(projectPath, projectStore);
|
|
||||||
}
|
|
||||||
const tasks = await projectStore.listTasks({ slim: true, includeArchived: false });
|
const tasks = await projectStore.listTasks({ slim: true, includeArchived: false });
|
||||||
return tasks.map((t) => ({
|
return tasks.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
@@ -1547,12 +1750,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
createTask: async (projectPath: string, input: { title: string; description?: string }) => {
|
createTask: async (projectPath: string, input: { title: string; description?: string }) => {
|
||||||
let projectStore = projectStores.get(projectPath);
|
const projectStore = await getProjectStore(projectPath);
|
||||||
if (!projectStore) {
|
|
||||||
projectStore = projectPath === cwd ? store : new TaskStore(projectPath);
|
|
||||||
if (projectPath !== cwd) await projectStore.init();
|
|
||||||
projectStores.set(projectPath, projectStore);
|
|
||||||
}
|
|
||||||
const created = await projectStore.createTask({
|
const created = await projectStore.createTask({
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description ?? input.title,
|
description: input.description ?? input.title,
|
||||||
@@ -1636,6 +1834,31 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
contextWindow: m.contextWindow ?? 0,
|
contextWindow: m.contextWindow ?? 0,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
git: {
|
||||||
|
getStatus: (projectPath: string) => buildGitStatus(projectPath),
|
||||||
|
listCommits: (projectPath: string, limit?: number) => buildGitCommits(projectPath, limit),
|
||||||
|
showCommit: (projectPath: string, sha: string) => buildGitCommitDetail(projectPath, sha),
|
||||||
|
listBranches: (projectPath: string) => buildGitBranches(projectPath),
|
||||||
|
listWorktrees: (projectPath: string) => buildGitWorktrees(projectPath),
|
||||||
|
push: async (projectPath: string) => {
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execFileAsync("git", ["push"], { cwd: projectPath, maxBuffer: 4 * 1024 * 1024 });
|
||||||
|
return { success: true, output: (stdout + stderr).trim() };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? (err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }).stderr ?? err.message : String(err);
|
||||||
|
return { success: false, output: msg.trim() };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetch: async (projectPath: string) => {
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execFileAsync("git", ["fetch"], { cwd: projectPath, maxBuffer: 4 * 1024 * 1024 });
|
||||||
|
return { success: true, output: (stdout + stderr).trim() };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? (err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }).stderr ?? err.message : String(err);
|
||||||
|
return { success: false, output: msg.trim() };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
.auth-token-recovery-overlay {
|
||||||
|
z-index: 210;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-token-recovery-modal {
|
||||||
|
width: min(560px, calc(100vw - (var(--space-xl) * 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-token-recovery-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-token-recovery-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
padding: var(--space-lg) var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-token-recovery-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-token-recovery-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-token-recovery-field label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-token-recovery-actions {
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import "./AuthTokenRecoveryDialog.css";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { clearAuthToken, setAuthToken } from "../auth";
|
||||||
|
|
||||||
|
export interface AuthTokenRecoveryDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps) {
|
||||||
|
const [tokenInput, setTokenInput] = useState("");
|
||||||
|
const tokenInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
tokenInputRef.current?.focus();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSetToken = useCallback(() => {
|
||||||
|
const token = tokenInput.trim();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
setAuthToken(token);
|
||||||
|
window.location.reload();
|
||||||
|
}, [tokenInput]);
|
||||||
|
|
||||||
|
const handleClearAndRetry = useCallback(() => {
|
||||||
|
clearAuthToken();
|
||||||
|
window.location.reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-overlay open auth-token-recovery-overlay"
|
||||||
|
role="presentation"
|
||||||
|
onKeyDownCapture={(event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal auth-token-recovery-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="auth-token-recovery-title"
|
||||||
|
aria-describedby="auth-token-recovery-description"
|
||||||
|
>
|
||||||
|
<div className="modal-header auth-token-recovery-header">
|
||||||
|
<h2 id="auth-token-recovery-title">Authentication token required</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-token-recovery-content">
|
||||||
|
<p id="auth-token-recovery-description">
|
||||||
|
This dashboard session can't authenticate with the daemon. Set a replacement token or clear the
|
||||||
|
current token and retry.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="auth-token-recovery-field">
|
||||||
|
<label htmlFor="auth-token-recovery-input">Replacement token</label>
|
||||||
|
<input
|
||||||
|
ref={tokenInputRef}
|
||||||
|
id="auth-token-recovery-input"
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={tokenInput}
|
||||||
|
onChange={(event) => setTokenInput(event.target.value)}
|
||||||
|
placeholder="Paste token"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-actions auth-token-recovery-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={handleClearAndRetry}
|
||||||
|
>
|
||||||
|
Clear token and retry
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSetToken}
|
||||||
|
disabled={tokenInput.trim().length === 0}
|
||||||
|
>
|
||||||
|
Set token and reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { AuthTokenRecoveryDialog } from "../AuthTokenRecoveryDialog";
|
||||||
|
import { clearAuthToken, setAuthToken } from "../../auth";
|
||||||
|
|
||||||
|
vi.mock("../../auth", () => ({
|
||||||
|
setAuthToken: vi.fn(),
|
||||||
|
clearAuthToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AuthTokenRecoveryDialog", () => {
|
||||||
|
const originalLocation = window.location;
|
||||||
|
let reloadSpy: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
reloadSpy = vi.fn();
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: { ...originalLocation, reload: reloadSpy },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render when closed", () => {
|
||||||
|
render(<AuthTokenRecoveryDialog open={false} />);
|
||||||
|
expect(screen.queryByRole("dialog", { name: "Authentication token required" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a blocking dialog with disabled set button until token is entered", () => {
|
||||||
|
render(<AuthTokenRecoveryDialog open={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog", { name: "Authentication token required" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /close/i })).toBeNull();
|
||||||
|
|
||||||
|
const setTokenButton = screen.getByRole("button", { name: "Set token and reload" });
|
||||||
|
expect(setTokenButton).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Replacement token"), { target: { value: "abc123" } });
|
||||||
|
expect(setTokenButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims and stores replacement token before reloading", () => {
|
||||||
|
render(<AuthTokenRecoveryDialog open={true} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Replacement token"), { target: { value: " new-token " } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Set token and reload" }));
|
||||||
|
|
||||||
|
expect(setAuthToken).toHaveBeenCalledWith("new-token");
|
||||||
|
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears token and reloads when user retries without replacement token", () => {
|
||||||
|
render(<AuthTokenRecoveryDialog open={true} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Clear token and retry" }));
|
||||||
|
|
||||||
|
expect(clearAuthToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not dismiss on Escape key", () => {
|
||||||
|
render(<AuthTokenRecoveryDialog open={true} />);
|
||||||
|
|
||||||
|
const overlay = document.querySelector(".auth-token-recovery-overlay");
|
||||||
|
expect(overlay).toBeTruthy();
|
||||||
|
|
||||||
|
if (!overlay) {
|
||||||
|
throw new Error("Expected auth token recovery overlay");
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.keyDown(overlay, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog", { name: "Authentication token required" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user