Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 49a02d8eb9 test(app): name the split resize helper arguments 2026-10-13 17:26:14 +02:00
paseo-bot[bot] ee6b095d0e test(app): take the page from the divider locator in the split resize spec 2026-10-13 17:26:14 +02:00
paseo-bot[bot] fb065afa2f refactor(app): choose split group sizes at the call site 2026-10-13 17:26:14 +02:00
paseo-bot[bot] 6eee6a6b3a fix(app): let every split divider drag after panes are added to a resized row
Divider drags store sizes per group id, apart from the layout tree. A group
keeps its id when panes are added, so after resizing a row and splitting it
again the stored list had fewer entries than panes, and dividers past its end
could not move. Use the dragged sizes only while they have one entry per pane.

Closes #5893
2026-10-13 17:26:13 +02:00
Elowen 5805b776c2 fix(git): open commit diffs from subdirectory workspaces (#6801)
* fix(git): resolve commit file diffs from the worktree root

* test(git): cover commit diffs from nested workspaces through daemon RPC
2026-10-13 15:52:54 +02:00
paseo-bot[bot] 4400445edb Allow worktree creation after Git initialization (#6785)
* fix(server): allow worktree creation after Git initialization

Fresh project checkout reads could publish Git capability while worktree creation retained a cached non-Git snapshot. Refresh an existing snapshot when the fresh read observes a classification change, before returning the new capability.

* test(server): cover Git initialization in the standard integration suite

* test(server): separate stress-test writes from deletion delivery

Wait for active classifications and queued deliveries between the distinct write and deletion phases. Otherwise a late create and delete coalesce as update, leaving the strict deletion assertion waiting indefinitely. Preserve every event assertion without waiting for scheduled native safety audits.

* test(server): compare canonical repository paths on Windows

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 13:55:25 +02:00
Elowen 337e81a9c6 fix(files): preserve relative symlinks when duplicating entries (#6771)
* fix(files): preserve relative symlinks when duplicating entries

* test(files): verify duplicated symlink targets through daemon RPC
2026-10-13 10:56:43 +02:00
9 changed files with 385 additions and 17 deletions
@@ -0,0 +1,101 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const DRAG_DISTANCE = -40;
function splitDividers(page: Page): Locator {
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
}
async function dividerX(divider: Locator): Promise<number> {
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
return box.x;
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX / 2, y, { steps: 4 });
await page.mouse.move(x + deltaX, y, { steps: 4 });
await page.mouse.up();
}
async function expectDividerFollowsDrag({
page,
index,
}: {
page: Page;
index: number;
}): Promise<void> {
const divider = splitDividers(page).nth(index);
const before = await dividerX(divider);
await dragDivider(divider, DRAG_DISTANCE);
await expect
.poll(async () => (await dividerX(divider)) - before, {
message: `divider ${index + 1} should follow the drag`,
})
.toBeLessThan(DRAG_DISTANCE * 0.75);
}
async function splitRight({
page,
expectedDividers,
}: {
page: Page;
expectedDividers: number;
}): Promise<void> {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(splitDividers(page)).toHaveCount(expectedDividers);
}
test.describe("Split pane resize", () => {
test("every divider drags in a four-pane row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-resize-fresh-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitRight({ page, expectedDividers: 1 });
await splitRight({ page, expectedDividers: 2 });
await splitRight({ page, expectedDividers: 3 });
for (const index of [0, 1, 2]) {
await expectDividerFollowsDrag({ page, index });
}
} finally {
await workspace.cleanup();
}
});
test("every divider drags after panes are added to a resized row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-resize-grown-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitRight({ page, expectedDividers: 1 });
await expectDividerFollowsDrag({ page, index: 0 });
await splitRight({ page, expectedDividers: 2 });
await splitRight({ page, expectedDividers: 3 });
for (const index of [0, 1, 2]) {
await expectDividerFollowsDrag({ page, index });
}
} finally {
await workspace.cleanup();
}
});
});
@@ -968,8 +968,12 @@ function SplitNodeView({
groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined,
);
const groupChildren = node.kind === "group" ? node.group.children : EMPTY_SPLIT_NODES;
// Dragged sizes live per group id, apart from the layout tree, and a group keeps its id when
// panes are added or removed. They only apply while they still have one entry per child;
// otherwise the dividers past the end of the list could not move.
const layoutGroupSizes = node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES;
const groupSizes =
storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES);
storedGroupSizes?.length === groupChildren.length ? storedGroupSizes : layoutGroupSizes;
const visibleFlex = useMemo(
() => resolveVisibleGroupFlex(groupChildren, groupSizes, maximizedPaneId),
[groupChildren, groupSizes, maximizedPaneId],
@@ -1,7 +1,16 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs";
import { describe, test, expect, beforeEach, afterEach, onTestFinished } from "vitest";
import {
mkdtempSync,
writeFileSync,
rmSync,
mkdirSync,
symlinkSync,
readdirSync,
readlinkSync,
} from "fs";
import { tmpdir } from "os";
import path from "path";
import { execFileSync } from "node:child_process";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
function tmpCwd(): string {
@@ -387,6 +396,84 @@ describe("file explorer", () => {
}
});
test.skipIf(process.platform === "win32")(
"duplicating a folder keeps relative symlinks inside the copy",
async () => {
const cwd = tmpCwd();
try {
mkdirSync(path.join(cwd, "template"));
writeFileSync(path.join(cwd, "template", "data.txt"), "original payload\n");
symlinkSync("data.txt", path.join(cwd, "template", "alias.txt"));
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "template" });
expect(duplicated).toMatchObject({
duplicatedPath: "template copy",
success: true,
error: null,
});
expect(readlinkSync(path.join(cwd, "template copy", "alias.txt"))).toBe("data.txt");
const copiedTarget = await ctx.client.readFile(cwd, "template copy/data.txt");
const targetWrite = await ctx.client.writeFile({
cwd,
path: "template copy/data.txt",
content: "copy payload\n",
expectedModifiedAt: copiedTarget.modifiedAt,
expectedRevision: copiedTarget.revision,
});
expect(targetWrite.status).toBe("written");
const copiedAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
expect(new TextDecoder().decode(copiedAlias.bytes)).toBe("copy payload\n");
const aliasWrite = await ctx.client.writeFile({
cwd,
path: "template copy/alias.txt",
content: "edited through copied alias\n",
expectedModifiedAt: copiedAlias.modifiedAt,
expectedRevision: copiedAlias.revision,
});
expect(aliasWrite.status).toBe("written");
const original = await ctx.client.readFile(cwd, "template/data.txt");
expect(new TextDecoder().decode(original.bytes)).toBe("original payload\n");
const editedCopy = await ctx.client.readFile(cwd, "template copy/data.txt");
expect(new TextDecoder().decode(editedCopy.bytes)).toBe("edited through copied alias\n");
const deleted = await ctx.client.deleteFileEntry({ cwd, path: "template" });
expect(deleted).toMatchObject({ success: true, error: null });
const survivingAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
expect(new TextDecoder().decode(survivingAlias.bytes)).toBe(
"edited through copied alias\n",
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"duplicating a file symlink preserves an explicit absolute target",
async () => {
const cwd = tmpCwd();
const targetPath = path.join(cwd, "data.txt");
try {
writeFileSync(targetPath, "absolute target payload\n");
symlinkSync(targetPath, path.join(cwd, "alias.txt"));
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "alias.txt" });
expect(duplicated).toMatchObject({
duplicatedPath: "alias copy.txt",
success: true,
error: null,
});
expect(readlinkSync(path.join(cwd, "alias copy.txt"))).toBe(targetPath);
const copy = await ctx.client.readFile(cwd, "alias copy.txt");
expect(new TextDecoder().decode(copy.bytes)).toBe("absolute target payload\n");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test("lists directory contents", async () => {
const cwd = tmpCwd();
@@ -489,3 +576,63 @@ describe("file explorer", () => {
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
});
describe("commit file diffs", () => {
test.each([".", "apps/web"])("opens committed files from %s workspaces", async (relativePath) => {
const cwd = tmpCwd();
onTestFinished(() =>
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
);
const workspace = path.join(cwd, relativePath);
const filePath = "apps/web/notes.txt";
function git(args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
git(["init", "-b", "main"]);
git(["config", "user.name", "Test"]);
git(["config", "user.email", "test@example.com"]);
git(["config", "core.autocrlf", "false"]);
mkdirSync(path.join(cwd, "apps", "web"), { recursive: true });
writeFileSync(path.join(cwd, filePath), "before\n");
writeFileSync(path.join(cwd, "apps/web/untouched.txt"), "unchanged\n");
git(["add", "."]);
git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]);
git(["checkout", "-b", "feature"]);
writeFileSync(path.join(cwd, filePath), "after\n");
git(["add", "."]);
git(["-c", "commit.gpgsign=false", "commit", "-m", "edit"]);
const sha = git(["rev-parse", "HEAD"]).trim();
const history = await ctx.client.listCheckoutCommits(workspace);
const commit = history.commits.find((entry) => entry.sha === sha);
expect(commit?.files).toEqual([
{ path: filePath, additions: 1, deletions: 1, status: "modified" },
]);
const result = await ctx.client.getCommitFileDiff(workspace, sha, filePath);
expect(result).toEqual({
file: {
path: filePath,
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
},
});
expect(await ctx.client.getCommitFileDiff(workspace, sha, "apps/web/untouched.txt")).toEqual({
file: null,
});
});
});
@@ -1,6 +1,6 @@
import { execFile as execFileCallback } from "node:child_process";
import { mkdtempSync, realpathSync } from "node:fs";
import { readFile, rm } from "node:fs/promises";
import { readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
@@ -63,10 +63,12 @@ test("an empty project becomes Git without changing its identity or creating a w
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: {} });
const projectEvents = client.observeEvents(["project.update"]);
await projectEvents.ready;
const added = await client.addProject(projectRoot);
expect(added).toEqual({
expect(added).toMatchObject({
requestId: expect.any(String),
project: {
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
@@ -77,7 +79,11 @@ test("an empty project becomes Git without changing its identity or creating a w
},
error: null,
});
const project = added.project!;
const {
projectIconRevision: _icon,
projectCustomIconRevision: _customIcon,
...project
} = added.project!;
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
@@ -96,15 +102,15 @@ test("an empty project becomes Git without changing its identity or creating a w
label: "project.update after git init",
});
expect(update).toEqual({
expect(update).toMatchObject({
kind: "upsert",
project: { ...project, projectKind: "git" },
project: { ...project, projectKind: "git", projectKey: expect.any(String) },
});
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(afterGitInit).toMatchObject({
entries: [],
emptyProjects: [{ ...project, projectKind: "git" }],
emptyProjects: [{ ...project, projectKind: "git", projectKey: expect.any(String) }],
});
const persistedProjects = JSON.parse(
@@ -119,5 +125,72 @@ test("an empty project becomes Git without changing its identity or creating a w
createdAt: expect.any(String),
updatedAt: expect.any(String),
archivedAt: null,
projectKey: expect.any(String),
customIconRevision: null,
});
}, 30_000);
test("a failed worktree attempt before git init does not prevent creation afterward", async () => {
const projectRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-worktree-")));
const paseoHomeRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-home-")));
cleanupPaths.add(projectRoot);
cleanupPaths.add(paseoHomeRoot);
await writeFile(path.join(projectRoot, "README.md"), "hi\n");
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
cleanupDaemons.add(daemon);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: {} });
const projectEvents = client.observeEvents(["project.update"]);
await projectEvents.ready;
const added = await client.addProject(projectRoot);
expect(added.project?.projectKind).toBe("non_git");
const source = {
kind: "worktree" as const,
cwd: projectRoot,
projectId: added.project!.projectId,
action: "branch-off" as const,
refName: "main",
branchName: "after-init",
worktreeSlug: "after-init",
};
const before = await client.createWorkspace({ source });
expect(before.error).toBe("Create worktree requires a git repository");
const gitProjectUpdate = waitForProjectUpdate(
client,
(payload) =>
payload.kind === "upsert" &&
payload.project.projectId === added.project!.projectId &&
payload.project.projectKind === "git",
);
await execFile("git", ["init", "-b", "main"], { cwd: projectRoot });
await execFile("git", ["add", "README.md"], { cwd: projectRoot });
await execFile(
"git",
[
"-c",
"user.name=Paseo Test",
"-c",
"user.email=test@getpaseo.dev",
"-c",
"commit.gpgSign=false",
"commit",
"-m",
"initial",
],
{ cwd: projectRoot },
);
await withTimeout({ promise: gitProjectUpdate, timeoutMs: 10_000, label: "project becomes Git" });
const projects = await client.fetchWorkspaces({
filter: { projectId: added.project!.projectId },
});
expect(projects.emptyProjects[0]?.projectKind).toBe("git");
const created = await client.createWorkspace({ source });
expect(created.error).toBeNull();
expect(created.workspace?.workspaceKind).toBe("worktree");
expect(created.workspace?.projectId).toBe(added.project!.projectId);
}, 30_000);
@@ -669,6 +669,7 @@ export async function duplicateExplorerEntry({
errorOnExist: true,
force: false,
preserveTimestamps: true,
verbatimSymlinks: true,
});
return { status: "ok", path: normalizeRelativePath({ root, targetPath }) };
} catch (error) {
@@ -236,7 +236,8 @@ test("observes a thousand concurrent writes and remains healthy after delete and
const deletions = expectFileChanges(removedPaths, "delete");
const movedFiles = expectFileChanges(movedPaths);
const recovery = expectFileChanges([sentinel]);
const subscription = await subscribeToFileChanges(root, (error, events) => {
const observer = createObserver();
const subscription = await observer.subscribe(root, (error, events) => {
expect(error).toBeNull();
for (const event of events) {
population.record(event);
@@ -254,9 +255,18 @@ test("observes a thousand concurrent writes and remains healthy after delete and
await Promise.all(paths.map((path, index) => writeFile(path, `${index}`)));
await writes.complete;
// A native watcher may recover coalesced paths through its safety audit.
// Completion comes from the delivered deletions, not an idle diagnostic or
// a deadline shorter than that recovery cycle.
// The first notice for each write can arrive before its remaining
// classifications. Drain those deliveries before starting a separate deletion
// phase, so a late create cannot coalesce with the expected delete as update.
await expect
.poll(() => observer.getDiagnostics(), { timeout: 10_000 })
.toMatchObject({
pendingEventCount: 0,
pendingClassificationCount: 0,
reconciliationInFlightCount: 0,
});
// Completion still requires the delivered deletion notices.
await Promise.all(removedPaths.map((path) => rm(path)));
await deletions.complete;
@@ -63,6 +63,27 @@ afterEach(async () => {
}
});
test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => {
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-")));
const repo = path.join(root, "repo");
mkdirSync(repo);
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(root, "home"),
});
cleanup.push(async () => {
await service.dispose();
rmSync(root, { recursive: true, force: true });
});
expect((await service.getSnapshot(repo, { includeForge: false })).git.isGit).toBe(false);
execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" });
expect((await service.getCheckout(repo)).isGit).toBe(true);
const repoRoot = await service.resolveRepoRoot(repo);
expect(realpathSync.native(repoRoot)).toBe(realpathSync.native(repo));
}, 30_000);
test("recursive observation updates tracked state and prunes ignored storms", async () => {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-git-observation-")));
const repoDir = path.join(tempDir, "repo");
@@ -765,6 +765,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
worktreesRoot: this.worktreesRoot,
logger: this.logger,
});
const cached = this.peekSnapshot(normalizedCwd);
if (cached && cached.git.isGit !== status.isGit) {
// A fresh checkout read discovered a repository transition. Keep the
// shared snapshot current before exposing the new project capability.
await this.getSnapshot(normalizedCwd, {
force: true,
includeForge: false,
reason: "checkout-classification-changed",
});
}
if (!status.isGit) {
return checkoutLiteFromGitSnapshot(normalizedCwd, {
isGit: false,
+5 -4
View File
@@ -2665,10 +2665,11 @@ export async function getCommitFileDiff({
sha: string;
path: string;
}): Promise<ParsedDiffFile | null> {
const worktreeRoot = await requireGitWorktreeRoot(cwd);
const { stdout } = await runGitCommand(
["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path],
{
cwd,
cwd: worktreeRoot,
envOverlay: READ_ONLY_GIT_ENV,
},
);
@@ -2677,9 +2678,9 @@ export async function getCommitFileDiff({
return null;
}
const parsedFiles = await parseAndHighlightDiff(stdout, cwd, {
getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path),
const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, {
getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path),
});
// `--` scopes the diff to a single pathspec, so there is at most one real