Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@
path = submodule/lix
url = https://github.com/opral/lix.git
branch = main
[submodule "submodule/atelier"]
path = submodule/atelier
url = https://github.com/opral/atelier.git
branch = main
16 changes: 10 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,27 @@
### Development

1. Clone the repository
2. Initialize the Lix submodule: `git submodule update --init --recursive`
2. Initialize the Lix and Atelier submodules: `git submodule update --init --recursive`
3. Install dependencies from the repo root: `pnpm install`
4. Build Lix dependencies (cached via Nx): `pnpm run build:lix`
5. Start the app: `pnpm run dev`
4. Build the vendored dependencies: `pnpm run build:lix && pnpm run build:atelier`
5. Start the app: `pnpm run dev` (the `predev` hook also builds both dependencies)

### Example

> [!INFO]
> `pnpm install` runs a postinstall step that installs Lix dependencies, so Nx can cache Lix builds.
> Lix and Atelier are vendored submodules and pnpm workspace packages. Lix uses
> Nx caching; Atelier builds its package output before FlashType starts or builds.

> [!INFO]
> `@glideapps/glide-data-grid` is used for the CSV viewer. Its published peer range has not caught up to React 19, so `package.json` intentionally allows the React 19 peer for Glide and lists Glide's peer packages explicitly.
> `@glideapps/glide-data-grid` is used for the CSV viewer. Its published peer
> range has not caught up to React 19, so `pnpm-workspace.yaml` intentionally
> allows the React 19 peer for Glide.

1. `git submodule update --init --recursive`
2. `pnpm install`
3. `pnpm run build:lix`
4. `pnpm run dev`
4. `pnpm run build:atelier`
5. `pnpm run dev`

### Opening a PR

Expand Down
43 changes: 33 additions & 10 deletions e2e/agent-review.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import type { ElectronApplication } from "playwright";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { chmod, mkdir, readFile, utimes, writeFile } from "node:fs/promises";
import path from "node:path";
import {
closeElectronApp,
Expand All @@ -13,18 +13,23 @@ import {

test.skip(process.platform === "win32", "fake codex helper is POSIX-only");

test.skip("restored markdown file shows a review after Codex edits it", async ({
test("Atelier reveals a review after Codex edits restored markdown", async ({
browserName: _browserName,
}, testInfo) => {
const userDataDir = testInfo.outputPath("user-data");
const workspaceDir = testInfo.outputPath("workspace");
const welcomeFilePath = path.join(workspaceDir, "welcome.md");
const changelogFilePath = path.join(workspaceDir, "changelog.md");
const createdFilePath = path.join(workspaceDir, "codex-created.md");
const fakeBinDir = testInfo.outputPath("fake-bin");
const originalPath = process.env.PATH;
const originalShell = process.env.SHELL;

let electronApp: ElectronApplication | undefined;
try {
await writeStarterFiles(workspaceDir);
const newestMarkdownTime = new Date(Date.now() + 1_000);
await utimes(welcomeFilePath, newestMarkdownTime, newestMarkdownTime);
await writeFile(
path.join(workspaceDir, "binary.bin"),
new Uint8Array([0x80, 0xff, 0x00]),
Expand Down Expand Up @@ -52,19 +57,31 @@ test.skip("restored markdown file shows a review after Codex edits it", async ({
await expect(page).toHaveTitle(path.basename(workspaceDir));
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();

await page.getByRole("button", { name: "Use Codex instead" }).click();
await page.locator('[data-attr="agent-start-codex"]').click();
await expect
.poll(
async () =>
await readFile(path.join(workspaceDir, "welcome.md"), "utf8"),
)
.poll(async () => await readFile(welcomeFilePath, "utf8"))
.toContain("Codex e2e edit");
await expect
.poll(async () => await readFile(changelogFilePath, "utf8"))
.toContain("Codex unopened edit");
await expect
.poll(async () => await readFile(createdFilePath, "utf8"))
.toContain("Codex created file");

await expect(
page.getByRole("group", { name: "External write review actions" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Keep" })).toBeVisible();
await expect(page.getByRole("button", { name: "Undo" })).toBeVisible();
await ensureFilesViewOpenInLeftPanel(page);
await expect(fileTreeFile(page, "/changelog.md")).toHaveAttribute(
"data-item-git-status",
"modified",
);
await expect(fileTreeFile(page, "/codex-created.md")).toHaveAttribute(
"data-item-git-status",
"modified",
);
} finally {
process.env.PATH = originalPath;
if (originalShell === undefined) {
Expand All @@ -84,7 +101,7 @@ async function openWelcomeMarkdown(page: Page): Promise<void> {
await expect(file).toHaveAttribute("data-item-selected", "true");
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
await expect(
page.locator('[data-active="true"][data-view-key="flashtype_file"]'),
page.locator('[data-active="true"][data-view-key="atelier_file"]'),
).toBeVisible();
}

Expand All @@ -100,7 +117,7 @@ async function expectOpenFilePersisted(
params: [key],
});
return JSON.stringify(result?.rows?.[0]?.[0] ?? null);
}, "flashtype_ui_state");
}, "atelier_ui_state");
})
.toContain(filePath);
}
Expand All @@ -111,11 +128,15 @@ async function writeFakeCodex(binDir: string): Promise<void> {
await writeFile(
scriptPath,
`#!/usr/bin/env node
import { appendFile } from "node:fs/promises";
import { appendFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { spawn } from "node:child_process";

const args = process.argv.slice(2);
if (args.includes("--version")) {
console.log("codex-cli 0.134.0");
process.exit(0);
}
const configs = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] === "-c") {
Expand All @@ -126,6 +147,8 @@ for (let index = 0; index < args.length; index += 1) {

await runHook("UserPromptSubmit", "turn-start");
await appendFile(join(process.cwd(), "welcome.md"), "\\nCodex e2e edit.\\n");
await appendFile(join(process.cwd(), "changelog.md"), "\\nCodex unopened edit.\\n");
await writeFile(join(process.cwd(), "codex-created.md"), "# Codex created file\\n");
await runHook("Stop", "turn-stop");
console.log("fake codex complete");

Expand Down
172 changes: 4 additions & 168 deletions e2e/branch-switcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ import {
registerRendererConsoleLogging,
} from "./electron-test-utils";

const deleteShortcut =
process.platform === "darwin" ? "Meta+Backspace" : "Control+Backspace";

test("persistent workspace branch switching keeps sidebar and disk on the active branch", async ({
browserName: _browserName,
}, testInfo) => {
Expand Down Expand Up @@ -455,22 +452,6 @@ async function initializeLixWorkspace(workspaceDir: string): Promise<void> {
await lix.close();
}

async function createMarkdownFileFromUi(
page: Page,
stem: string,
): Promise<void> {
const appPath = `/${stem}.md`;
await ensureFilesViewOpenInLeftPanel(page);
await page.getByRole("button", { name: "New file", exact: true }).click();
const renameInput = page.locator("[data-item-rename-input]").first();
await expect(renameInput).toBeVisible();
await renameInput.fill(stem);
await renameInput.press("Enter");
await expect(fileTreeFile(page, appPath)).toBeVisible();
await expectActiveCentralFile(page, appPath);
await expectEditableMarkdown(page);
}

async function openMarkdownFileFromTree(
page: Page,
appPath: string,
Expand All @@ -482,59 +463,6 @@ async function openMarkdownFileFromTree(
await expectActiveCentralFile(page, appPath);
}

async function deleteMarkdownFileFromUi(
page: Page,
appPath: string,
): Promise<void> {
await ensureFilesViewOpenInLeftPanel(page);
const file = fileTreeFile(page, appPath);
await expect(file).toBeVisible();
await file.click();
await expect(file).toHaveAttribute("data-item-selected", "true");
await page.keyboard.press(deleteShortcut);
await expect(file).toHaveCount(0);
await expect
.poll(async () => await lixFileExistsByPath(page, appPath))
.toBe(false);
}

async function typeLineInActiveMarkdown(
page: Page,
appPath: string,
line: string,
): Promise<void> {
await expectActiveCentralFile(page, appPath);
const editor = page.locator('[data-testid="tiptap-editor"] .ProseMirror');
await expect(editor).toBeVisible();
await focusEditableMarkdownEnd(page, editor);
const existingText = (await editor.innerText()).trim();
if (existingText.length > 0) {
await page.keyboard.press("Enter");
}
await page.keyboard.type(line);
await expect(editor).toContainText(line);
await expectLixFileToContain(page, appPath, line);
}

async function focusEditableMarkdownEnd(
page: Page,
editor: ReturnType<Page["locator"]>,
): Promise<void> {
await editor.click();
await page.keyboard.press(
process.platform === "darwin" ? "Meta+ArrowDown" : "Control+End",
);
}

async function waitForNextRendererTimestampSecond(page: Page): Promise<void> {
const startedAtSecond = await page.evaluate(() =>
Math.floor(Date.now() / 1000),
);
await expect
.poll(async () => await page.evaluate(() => Math.floor(Date.now() / 1000)))
.not.toBe(startedAtSecond);
}

async function createCheckpointFromUi(page: Page): Promise<string> {
const beforeIds = await branchIdsFromUi(page);
await ensureHistoryViewOpenInLeftPanel(page);
Expand Down Expand Up @@ -877,36 +805,6 @@ async function newBranchIdFromUi(
}, beforeIds);
}

async function branchCommitIdFromUi(
page: Page,
branchId: string,
): Promise<string> {
await expect
.poll(async () => {
return await page.evaluate(async (id) => {
const result = await window.flashtypeDesktop?.lix.execute({
sql: "SELECT commit_id FROM lix_branch WHERE id = $1",
params: [id],
});
const value = result?.rows?.[0]?.[0];
return typeof value === "string" && value.length > 0 ? value : null;
}, branchId);
})
.not.toBeNull();
const commitId = await page.evaluate(async (id) => {
const result = await window.flashtypeDesktop?.lix.execute({
sql: "SELECT commit_id FROM lix_branch WHERE id = $1",
params: [id],
});
const value = result?.rows?.[0]?.[0];
return typeof value === "string" && value.length > 0 ? value : null;
}, branchId);
if (!commitId) {
throw new Error(`Commit id for branch ${branchId} was not found.`);
}
return commitId;
}

async function initialCommitIdForCommitFromUi(
page: Page,
commitId: string,
Expand Down Expand Up @@ -1009,7 +907,7 @@ async function activeCentralDocumentIdentityFromUi(
return await page.evaluate(async () => {
const result = await window.flashtypeDesktop?.lix.execute({
sql: "SELECT value FROM lix_key_value_by_branch WHERE key = $1 AND lixcol_branch_id = $2",
params: ["flashtype_ui_state", "global"],
params: ["atelier_ui_state", "global"],
});
const state = result?.rows?.[0]?.[0] as
| {
Expand Down Expand Up @@ -1046,17 +944,11 @@ async function activeCentralDocumentIdentityFromUi(
});
}

async function expectNoActiveCentralFile(page: Page): Promise<void> {
await expect
.poll(async () => await activeCentralFilePathFromUi(page))
.toBeNull();
}

async function activeCentralFilePathFromUi(page: Page): Promise<string | null> {
return await page.evaluate(async () => {
const result = await window.flashtypeDesktop?.lix.execute({
sql: "SELECT value FROM lix_key_value_by_branch WHERE key = $1 AND lixcol_branch_id = $2",
params: ["flashtype_ui_state", "global"],
params: ["atelier_ui_state", "global"],
});
const state = result?.rows?.[0]?.[0] as
| {
Expand Down Expand Up @@ -1107,7 +999,7 @@ async function documentSlotViolationsFromUi(page: Page): Promise<string[]> {
return await page.evaluate(async () => {
const result = await window.flashtypeDesktop?.lix.execute({
sql: "SELECT value FROM lix_key_value_by_branch WHERE key = $1 AND lixcol_branch_id = $2",
params: ["flashtype_ui_state", "global"],
params: ["atelier_ui_state", "global"],
});
type ViewState = {
readonly fileId?: unknown;
Expand Down Expand Up @@ -1174,7 +1066,7 @@ async function activeEditorRevisionStateFromUi(page: Page): Promise<{
return await page.evaluate(async () => {
const result = await window.flashtypeDesktop?.lix.execute({
sql: "SELECT value FROM lix_key_value_by_branch WHERE key = $1 AND lixcol_branch_id = $2",
params: ["flashtype_ui_state", "global"],
params: ["atelier_ui_state", "global"],
});
const state = result?.rows?.[0]?.[0] as
| {
Expand Down Expand Up @@ -1294,62 +1186,6 @@ async function expectMarkdownDiff(
).toHaveCount(0);
}

async function expectLixFileToContain(
page: Page,
appPath: string,
text: string,
): Promise<void> {
await expect
.poll(async () => await lixFileTextByPath(page, appPath))
.toContain(text);
}

async function lixFileExistsByPath(
page: Page,
appPath: string,
): Promise<boolean> {
return await page.evaluate(async (path) => {
const result = await window.flashtypeDesktop?.lix.execute({
sql: "SELECT 1 FROM lix_file WHERE path = $1 LIMIT 1",
params: [path],
});
return (result?.rows?.length ?? 0) > 0;
}, appPath);
}

async function lixFileTextByPath(page: Page, appPath: string): Promise<string> {
return (
(await page.evaluate(async (path) => {
const result = await window.flashtypeDesktop?.lix.execute({
sql: "SELECT data FROM lix_file WHERE path = $1",
params: [path],
});
const value = result?.rows?.[0]?.[0];
return decodeSqlText(value);

function decodeSqlText(value: unknown): string {
if (value instanceof Uint8Array) return new TextDecoder().decode(value);
if (value instanceof ArrayBuffer) {
return new TextDecoder().decode(new Uint8Array(value));
}
if (ArrayBuffer.isView(value)) {
const view = value as Uint8Array;
return new TextDecoder().decode(
new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
);
}
if (Array.isArray(value)) {
return new TextDecoder().decode(new Uint8Array(value as number[]));
}
if (value && typeof value === "object" && "value" in value) {
return decodeSqlText((value as { value: unknown }).value);
}
return typeof value === "string" ? value : "";
}
}, appPath)) ?? ""
);
}

async function checkpointRowLabels(page: Page): Promise<string[]> {
return await page
.locator('[data-attr="branch-diff"]')
Expand Down
Loading
Loading