feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)
- Changes panel: Files and Changes merged into one panel; the overview's groups drawn as a folder tree (compacted chains, file counts, fold, stage/unstage a whole folder) - AI-suggested commit message for what is staged (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku) - AI Router: optional "Behaves as" Claude model id, written as modelPicker options into the router's --settings file (issue #56) - Sidebar: session rows show last activity, live rows their start time; a closed Terminal row can be removed (DELETE /api/run/history/:id) - Every pane's CLI starts only once a client is attached; claude panes no longer forced inline; tmux scrollbar one column wide - Update notifier: "Check for updates" reopens a closed notice
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's folder tree: grouping by folder,
|
||||
* compacting single-child chains, counts, ordering and the per-folder file list.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { changeTree, treeFiles } from "../changeTree";
|
||||
|
||||
const rows = [
|
||||
"app2/Services/TwitterService.php",
|
||||
"app2/Services/Redis/SlowAlertRedisService.php",
|
||||
"app2/Enums/Redis/RedisSlowAlertKey.php",
|
||||
"config/monitoring.php",
|
||||
"config/app.php",
|
||||
"README.md",
|
||||
].map((path) => ({ path }));
|
||||
|
||||
describe("changeTree", () => {
|
||||
const root = changeTree(rows);
|
||||
|
||||
it("puts root files at the top level and counts every file", () => {
|
||||
expect(root.count).toBe(6);
|
||||
expect(root.files.map((f) => f.name)).toEqual(["README.md"]);
|
||||
expect(root.dirs.map((d) => d.name)).toEqual(["app2", "config"]);
|
||||
});
|
||||
|
||||
it("compacts a folder chain with nothing else in it", () => {
|
||||
const app2 = root.dirs[0]!;
|
||||
expect(app2.count).toBe(3);
|
||||
expect(app2.dirs.map((d) => [d.name, d.path])).toEqual([
|
||||
["Enums/Redis", "app2/Enums/Redis"],
|
||||
["Services", "app2/Services"],
|
||||
]);
|
||||
// Services holds a file, so it is not folded into Redis.
|
||||
expect(app2.dirs[1]!.dirs[0]!.name).toBe("Redis");
|
||||
});
|
||||
|
||||
it("sorts files by name and lists a folder's files", () => {
|
||||
expect(root.dirs[1]!.files.map((f) => f.name)).toEqual(["app.php", "monitoring.php"]);
|
||||
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -2186,6 +2186,9 @@ export const api = {
|
||||
if (opts.limit) qs.set("limit", String(opts.limit));
|
||||
return request<{ items: RunHistoryItem[] }>(`/run/history?${qs.toString()}`);
|
||||
},
|
||||
/** DELETE /api/run/history/:id - forget a closed Terminal tab (shell rows only). */
|
||||
forgetHistory: (id: string) =>
|
||||
request<{ removed: string }>(`/run/history/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
|
||||
binary: (tool: LaneTool = "claude") =>
|
||||
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
|
||||
@@ -2757,6 +2760,12 @@ export const api = {
|
||||
current: string | null;
|
||||
worktrees: LaneWorktree[];
|
||||
}>(gitUrl(id, "/branches")),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
|
||||
* written by the user's `claude` CLI (no tools, nothing persisted).
|
||||
*/
|
||||
suggestCommitMessage: (id: GitTarget) =>
|
||||
request<{ message: string }>(gitUrl(id, "/commit-message"), { method: "POST" }),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
|
||||
* @param id The lane id.
|
||||
@@ -3286,6 +3295,8 @@ export interface AiRouter {
|
||||
hasKey: boolean;
|
||||
model: string;
|
||||
models: AiRouterModels;
|
||||
/** Claude model id the router's own model names behave as; "" when unset. */
|
||||
behavesAs?: string;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
@@ -3318,6 +3329,8 @@ export interface AiRouterInput {
|
||||
model: string;
|
||||
/** Any tier left out is filled from `model`. */
|
||||
models?: Partial<AiRouterModels>;
|
||||
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||
behavesAs?: string;
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file Folds the Changes panel's flat list of changed files into a folder
|
||||
* tree, JetBrains' commit-view shape: folders first, then files, each sorted
|
||||
* by name, and a chain of folders that hold nothing but one sub-folder drawn
|
||||
* as ONE row (`src/lib`), so a deep path does not cost a row per level.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export interface ChangeTreeDir<T> {
|
||||
/** The row's label — one segment, or several joined by `/` when compacted. */
|
||||
name: string;
|
||||
/** The folder's repo-relative path ("" for the root). */
|
||||
path: string;
|
||||
dirs: ChangeTreeDir<T>[];
|
||||
files: { name: string; file: T }[];
|
||||
/** Every file anywhere below. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
|
||||
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
|
||||
for (const file of rows) {
|
||||
const parts = file.path.split("/");
|
||||
let dir = root;
|
||||
dir.count++;
|
||||
for (const seg of parts.slice(0, -1)) {
|
||||
const path = dir.path ? `${dir.path}/${seg}` : seg;
|
||||
let next = dir.dirs.find((d) => d.path === path);
|
||||
if (!next) dir.dirs.push((next = { name: seg, path, dirs: [], files: [], count: 0 }));
|
||||
next.count++;
|
||||
dir = next;
|
||||
}
|
||||
dir.files.push({ name: parts[parts.length - 1] ?? file.path, file });
|
||||
}
|
||||
const finish = (d: ChangeTreeDir<T>): ChangeTreeDir<T> => {
|
||||
// Never the root: its children are the group's top level.
|
||||
let only: ChangeTreeDir<T> | undefined;
|
||||
while (d.path && d.files.length === 0 && d.dirs.length === 1 && (only = d.dirs[0])) {
|
||||
d = { ...only, name: `${d.name}/${only.name}` };
|
||||
}
|
||||
d.dirs = d.dirs.map(finish).sort((a, b) => a.name.localeCompare(b.name));
|
||||
d.files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return d;
|
||||
};
|
||||
return finish(root);
|
||||
}
|
||||
|
||||
/** Every file under a folder, for its stage/unstage-all button. */
|
||||
export function treeFiles<T>(d: ChangeTreeDir<T>): T[] {
|
||||
return [...d.files.map((f) => f.file), ...d.dirs.flatMap(treeFiles)];
|
||||
}
|
||||
Reference in New Issue
Block a user