diff --git a/canvas/src/components/tabs/FilesTab/__tests__/FileEditor.test.tsx b/canvas/src/components/tabs/FilesTab/__tests__/FileEditor.test.tsx
new file mode 100644
index 00000000..90e87be4
--- /dev/null
+++ b/canvas/src/components/tabs/FilesTab/__tests__/FileEditor.test.tsx
@@ -0,0 +1,283 @@
+// @vitest-environment jsdom
+/**
+ * Tests for FileEditor — the text editor pane in the Files tab.
+ *
+ * FileEditor is fully prop-driven (no stores, no API calls).
+ * All props passed explicitly per-test to avoid defaultProps + vi.fn()
+ * module-scope issues in React 19.
+ *
+ * Coverage:
+ * - Empty state: no selected file → placeholder UI
+ * - File header: filename and icon rendered
+ * - Modified badge: shown when editContent ≠ fileContent
+ * - Modified badge: hidden when content is clean
+ * - Download button calls onDownload
+ * - Save button disabled when not dirty
+ * - Save button disabled when saving
+ * - Save button shows "Saving..." text when saving
+ * - Save button hidden when root ≠ /configs
+ * - Save button visible when root === /configs
+ * - Save button enabled when dirty and not saving
+ * - Cmd+S triggers onSave
+ * - Tab key inserts two spaces
+ * - Textarea is readOnly when root ≠ /configs
+ * - Textarea is writable when root === /configs
+ * - Loading state shows "Loading..." text
+ * - onChange updates editContent
+ * - Success message displayed when success prop is set
+ */
+import React from "react";
+import { render, screen, fireEvent, cleanup } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { FileEditor } from "../FileEditor";
+
+function makeProps(overrides = {}) {
+ return {
+ selectedFile: null as string | null,
+ fileContent: "",
+ editContent: "",
+ setEditContent: vi.fn<(v: string) => void>(),
+ loadingFile: false,
+ saving: false,
+ success: null as string | null,
+ root: "/workspace",
+ onSave: vi.fn(),
+ onDownload: vi.fn(),
+ ...overrides,
+ };
+}
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+// ─── Empty state ───────────────────────────────────────────────────────────────
+
+describe("FileEditor — empty state", () => {
+ it("shows placeholder when no file selected", () => {
+ render();
+ expect(screen.getByText("Select a file to edit")).toBeTruthy();
+ expect(screen.getByText("📄")).toBeTruthy();
+ });
+
+ it("does NOT render textarea when no file selected", () => {
+ render();
+ expect(screen.queryByRole("textbox")).toBeFalsy();
+ });
+});
+
+// ─── File header ──────────────────────────────────────────────────────────────
+
+describe("FileEditor — file header", () => {
+ it("shows the selected filename in monospace", () => {
+ render();
+ expect(screen.getByText("src/main.py")).toBeTruthy();
+ });
+
+ it("shows the correct icon for a Python file", () => {
+ render();
+ expect(screen.getByText("🐍")).toBeTruthy();
+ });
+
+ it("shows the correct icon for a TypeScript file", () => {
+ render();
+ expect(screen.getByText("💠")).toBeTruthy();
+ });
+});
+
+// ─── Dirty state ───────────────────────────────────────────────────────────────
+
+describe("FileEditor — dirty/modified state", () => {
+ it("shows 'modified' badge when editContent differs from fileContent", () => {
+ render(
+
+ );
+ expect(screen.getByText("modified")).toBeTruthy();
+ });
+
+ it("does NOT show 'modified' badge when content matches", () => {
+ render(
+
+ );
+ expect(screen.queryByText("modified")).toBeFalsy();
+ });
+
+});
+
+// ─── Download button ────────────────────────────────────────────────────────────
+
+describe("FileEditor — download", () => {
+ it("renders a Download button with aria-label", () => {
+ render();
+ expect(screen.getByRole("button", { name: /download/i })).toBeTruthy();
+ });
+
+ it("calls onDownload when Download button is clicked", () => {
+ const onDownload = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /download/i }));
+ expect(onDownload).toHaveBeenCalledTimes(1);
+ });
+});
+
+// ─── Save button ───────────────────────────────────────────────────────────────
+
+describe("FileEditor — save button", () => {
+ it("renders a Save button when root is /configs", () => {
+ render();
+ expect(screen.getByRole("button", { name: /save/i })).toBeTruthy();
+ });
+
+ it("Save button is NOT rendered when root is /workspace", () => {
+ render();
+ expect(screen.queryByRole("button", { name: /save/i })).toBeFalsy();
+ });
+
+ it("Save button is NOT rendered when root is /files", () => {
+ render();
+ expect(screen.queryByRole("button", { name: /save/i })).toBeFalsy();
+ });
+
+ it("Save button is disabled when content is clean (not dirty)", () => {
+ render(
+
+ );
+ // Use exact match to avoid matching "Saving..." which also contains "save"
+ const btn = screen.getByRole("button", { name: /^Save$/i });
+ expect(btn.hasAttribute("disabled")).toBe(true);
+ });
+
+ it("Save button is enabled when dirty and not saving", () => {
+ render(
+
+ );
+ const btn = screen.getByRole("button", { name: /^Save$/i });
+ expect(btn.hasAttribute("disabled")).toBe(false);
+ });
+
+ it("Save button is disabled when saving is true", () => {
+ render(
+
+ );
+ const btn = screen.getByRole("button", { name: /saving/i });
+ expect(btn.hasAttribute("disabled")).toBe(true);
+ });
+
+ it("Save button shows 'Saving...' when saving", () => {
+ render();
+ expect(screen.getByText("Saving...")).toBeTruthy();
+ });
+
+ it("Save button shows 'Save' when not saving", () => {
+ render();
+ expect(screen.getByText("Save")).toBeTruthy();
+ });
+
+ it("calls onSave when Save button is clicked", () => {
+ const onSave = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole("button", { name: /save/i }));
+ expect(onSave).toHaveBeenCalledTimes(1);
+ });
+});
+
+// ─── Keyboard shortcuts ───────────────────────────────────────────────────────
+
+describe("FileEditor — keyboard shortcuts", () => {
+ it("Cmd+S triggers onSave in textarea", () => {
+ const onSave = vi.fn();
+ render();
+ const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
+ textarea.focus();
+ fireEvent.keyDown(textarea, { key: "s", metaKey: true });
+ expect(onSave).toHaveBeenCalledTimes(1);
+ });
+
+ it("Tab inserts two spaces at cursor position", () => {
+ // Use a real state variable so the Tab handler reads the correct updated value.
+ // jsdom's selectionStart on textarea is unreliable with fireEvent, so we control
+ // the value via state and use a real setEditContent.
+ let editContent = "hello";
+ const setEditContent = vi.fn((v: string) => { editContent = v; });
+ const { rerender } = render(
+
+ );
+ const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
+ // jsdom textarea selectionStart getter is read from the element's _value; force it.
+ Object.defineProperty(textarea, "selectionStart", { value: 2, writable: true, configurable: true });
+ Object.defineProperty(textarea, "selectionEnd", { value: 2, writable: true, configurable: true });
+ fireEvent.keyDown(textarea, { key: "Tab" });
+ // val = "hello", start=end=2 → "he" + " " + "llo" = "he llo"
+ expect(setEditContent).toHaveBeenCalledWith("he llo");
+ });
+});
+
+// ─── Textarea ─────────────────────────────────────────────────────────────────
+
+describe("FileEditor — textarea", () => {
+ it("renders textarea with the current editContent value", () => {
+ render();
+ expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe("hello world");
+ });
+
+ it("calls setEditContent on change", () => {
+ const setEditContent = vi.fn();
+ render();
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "new text" } });
+ expect(setEditContent).toHaveBeenCalledWith("new text");
+ });
+
+ it("textarea is readOnly when root is /workspace", () => {
+ render();
+ expect((screen.getByRole("textbox") as HTMLTextAreaElement).readOnly).toBe(true);
+ });
+
+ it("textarea is readOnly when root is /files", () => {
+ render();
+ expect((screen.getByRole("textbox") as HTMLTextAreaElement).readOnly).toBe(true);
+ });
+
+ it("textarea is writable when root is /configs", () => {
+ render();
+ expect((screen.getByRole("textbox") as HTMLTextAreaElement).readOnly).toBe(false);
+ });
+});
+
+// ─── Loading state ─────────────────────────────────────────────────────────────
+
+describe("FileEditor — loading state", () => {
+ it("shows 'Loading...' when loadingFile is true", () => {
+ render();
+ expect(screen.getByText("Loading...")).toBeTruthy();
+ });
+
+ it("hides textarea when loadingFile is true", () => {
+ render();
+ expect(screen.queryByRole("textbox")).toBeFalsy();
+ });
+});
+
+// ─── Success message ──────────────────────────────────────────────────────────
+
+describe("FileEditor — success message", () => {
+ it("shows success message when success prop is set", () => {
+ render();
+ expect(screen.getByText("Saved!")).toBeTruthy();
+ });
+
+ it("success message uses good colour class", () => {
+ render();
+ const msg = screen.getByText("Done");
+ expect(msg.className).toContain("text-good");
+ });
+
+ it("does NOT render success element when success is null", () => {
+ render();
+ const header = screen.getByText("cfg.yaml").closest("div");
+ const successEl = header?.querySelector('[class*="text-good"]');
+ expect(successEl).toBeFalsy();
+ });
+});