diff --git a/biome.jsonc b/biome.jsonc index 3f124787..4d94c68f 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -9,11 +9,20 @@ // App-server protocol and generated binding boundaries. { "path": "./scripts/lint/no-app-server-projection-rpcs.grit", - "includes": ["**/src/features/chat/application/**/*.ts", "**/src/features/chat/application/**/*.tsx"] + "includes": ["**/src/features/chat/application/**/*.ts"] }, { "path": "./scripts/lint/no-app-server-protocol-boundary-imports.grit", - "includes": ["**/src/**/*.ts", "**/src/**/*.tsx", "!**/src/app-server/**/*.ts", "!**/src/app-server/**/*.tsx"] + "includes": [ + "**/src/**/*.ts", + "!**/src/app-server/**/*.ts", + "**/src/features/chat/panel/**/*.tsx", + "**/src/features/chat/ui/**/*.tsx", + "**/src/features/selection-rewrite/**/*.tsx", + "**/src/features/threads-view/**/*.tsx", + "**/src/settings/**/*.tsx", + "**/src/shared/ui/**/*.tsx" + ] }, { "path": "./scripts/lint/no-generated-app-server-boundary-imports.grit", @@ -28,27 +37,15 @@ // Shared source layering boundaries. { "path": "./scripts/lint/no-domain-outer-layer-imports.grit", - "includes": [ - "**/src/domain/**/*.ts", - "**/src/domain/**/*.tsx", - "**/src/features/chat/domain/**/*.ts", - "**/src/features/chat/domain/**/*.tsx" - ] + "includes": ["**/src/domain/**/*.ts", "**/src/features/chat/domain/**/*.ts"] }, { "path": "./scripts/lint/no-lower-level-feature-imports.grit", - "includes": ["**/src/app-server/**/*.ts", "**/src/app-server/**/*.tsx", "**/src/shared/**/*.ts", "**/src/shared/**/*.tsx"] + "includes": ["**/src/app-server/**/*.ts", "**/src/shared/**/*.ts", "**/src/shared/**/*.tsx"] }, { "path": "./scripts/lint/no-app-server-connection-boundary-imports.grit", - "includes": [ - "**/src/app-server/protocol/**/*.ts", - "**/src/app-server/protocol/**/*.tsx", - "**/src/domain/**/*.ts", - "**/src/domain/**/*.tsx", - "**/src/shared/**/*.ts", - "**/src/shared/**/*.tsx" - ] + "includes": ["**/src/app-server/protocol/**/*.ts", "**/src/domain/**/*.ts", "**/src/shared/**/*.ts", "**/src/shared/**/*.tsx"] }, // Chat feature architecture. @@ -58,7 +55,7 @@ }, { "path": "./scripts/lint/no-pure-chat-state-side-effects.grit", - "includes": ["**/src/features/chat/application/state/**/*.ts", "**/src/features/chat/application/state/**/*.tsx"] + "includes": ["**/src/features/chat/application/state/**/*.ts"] }, // UI and DOM source boundaries. @@ -76,6 +73,18 @@ }, // Project-wide source shape. + { + "path": "./scripts/lint/no-misplaced-tsx.grit", + "includes": [ + "**/src/**/*.tsx", + "!**/src/features/chat/panel/**", + "!**/src/features/chat/ui/**", + "!**/src/features/selection-rewrite/**", + "!**/src/features/threads-view/**", + "!**/src/settings/**", + "!**/src/shared/ui/**" + ] + }, { "path": "./scripts/lint/no-handwritten-reexports.grit", "includes": ["**/*.ts", "**/*.tsx", "**/*.mjs"] diff --git a/docs/development.md b/docs/development.md index bc40cdf2..bb18a8e8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -63,6 +63,8 @@ Chat panel-visible state belongs in `ChatStateStore` and should flow through nam Use imperative DOM writes only for explicit bridge modules, Obsidian-owned API boundaries, or rendering and measurement code that cannot be expressed cleanly as Preact components. Name those files with a `.dom`, `.obsidian`, or `.measure` suffix so Biome can enforce the boundary without file-specific allowlists. +Use `.tsx` only in rendering-owned source folders: chat panel and UI modules, Obsidian/settings surfaces, selection rewrite and threads view renderers, and shared UI components. Non-rendering source should use `.ts`; Biome enforces this so lower-level app-server, domain, application, presentation, workspace, and generic shared modules do not grow JSX dependencies. + ## CSS Rules CSS should stay native to Obsidian. Prefer Obsidian variables and Codex Panel tokens for color, typography, spacing, and layout dimensions instead of hardcoded values. diff --git a/scripts/lint/no-misplaced-tsx.grit b/scripts/lint/no-misplaced-tsx.grit new file mode 100644 index 00000000..e317bbf0 --- /dev/null +++ b/scripts/lint/no-misplaced-tsx.grit @@ -0,0 +1,5 @@ +language js + +`$program` where { + register_diagnostic(span=$program, message="Keep TSX files in UI, panel, settings, or explicit render bridge modules; non-rendering source should use .ts.", severity="error") +} diff --git a/src/features/chat/ui/message-stream/detail.tsx b/src/features/chat/ui/message-stream/detail.tsx index 8955bd18..d700567d 100644 --- a/src/features/chat/ui/message-stream/detail.tsx +++ b/src/features/chat/ui/message-stream/detail.tsx @@ -1,6 +1,6 @@ import type { ComponentChild as UiNode } from "preact"; -import { RawDiffLines } from "../../../../shared/diff/render"; +import { RawDiffView } from "../../../../shared/ui/diff"; import type { DetailSection, DetailView } from "../../presentation/message-stream/detail-view"; import type { MessageStreamDisclosureState } from "./context"; @@ -84,7 +84,7 @@ function DetailSectionView({ section }: { section: DetailSection }): UiNode { if (section.kind === "diff") { return ( - + ); } @@ -139,7 +139,3 @@ function OutputSection({ title, className, children }: { title: string; classNam ); } - -function DiffLines({ diff }: { diff: string }): UiNode { - return ; -} diff --git a/src/features/chat/ui/turn-diff/render.dom.tsx b/src/features/chat/ui/turn-diff/render.dom.tsx index 02238bc1..0f71b3d6 100644 --- a/src/features/chat/ui/turn-diff/render.dom.tsx +++ b/src/features/chat/ui/turn-diff/render.dom.tsx @@ -1,9 +1,8 @@ import type { ComponentChild as UiNode } from "preact"; -import { DisplayDiffLines } from "../../../../shared/diff/render"; -import { displayDiffLines } from "../../../../shared/diff/unified"; import { shortThreadId } from "../../../../shared/id/thread-id"; import { IconButton } from "../../../../shared/ui/components.obsidian"; +import { UnifiedDiffView } from "../../../../shared/ui/diff"; import { renderUiRoot } from "../../../../shared/ui/ui-root.dom"; import type { ChatTurnDiffViewState, PersistedChatTurnDiffViewState } from "../../domain/turn-diff"; @@ -46,7 +45,7 @@ function ChatTurnDiffView({ <> {state.files.length > 0 ? : null} - + ); } @@ -86,10 +85,6 @@ function ChangedFiles({ files }: { files: string[] }): UiNode { ); } -function UnifiedDiff({ diff }: { diff: string }): UiNode { - return ; -} - function fileCountLabel(files: string[]): string { return files.length === 1 ? "Edited 1 file" : `Edited ${String(files.length)} files`; } diff --git a/src/features/selection-rewrite/popover.dom.tsx b/src/features/selection-rewrite/popover.dom.tsx index f0502043..55d1f8e3 100644 --- a/src/features/selection-rewrite/popover.dom.tsx +++ b/src/features/selection-rewrite/popover.dom.tsx @@ -1,9 +1,8 @@ import { type Editor, Notice } from "obsidian"; import type { TargetedKeyboardEvent, ComponentChild as UiNode } from "preact"; -import { DisplayDiffLines } from "../../shared/diff/render"; -import { displayDiffLines } from "../../shared/diff/unified"; import { IconButton } from "../../shared/ui/components.obsidian"; +import { DiffLineList, unifiedDiffDisplayLines } from "../../shared/ui/diff"; import { isComposerSendKey, type SendShortcut } from "../../shared/ui/keyboard"; import { syncTextareaHeight } from "../../shared/ui/textarea-autogrow.measure"; import { type TextareaCaretBoundaryDirection, textareaCursorAtVisualBoundary } from "../../shared/ui/textarea-caret.measure"; @@ -415,8 +414,8 @@ function SelectionRewriteStatus({ status }: { status: SelectionRewriteSessionSta function SelectionRewriteDiff({ diff }: { diff: string }): UiNode { return ( - line.kind !== "file" && !line.text.startsWith("@@"))} + line.kind !== "file" && !line.text.startsWith("@@"))} className="codex-panel-selection-rewrite__diff-body" /> ); diff --git a/src/shared/diff/unified.ts b/src/shared/diff/unified.ts deleted file mode 100644 index 89ddc73e..00000000 --- a/src/shared/diff/unified.ts +++ /dev/null @@ -1,46 +0,0 @@ -export interface DisplayDiffLine { - text: string; - kind?: "file"; -} - -export type DiffLineClass = "added" | "removed" | "hunk" | "context" | "file"; - -export function displayDiffLines(diff: string): DisplayDiffLine[] { - const displayLines: DisplayDiffLine[] = []; - let inFileHeader = false; - for (const line of diff.split("\n")) { - const file = filePathFromGitDiffHeader(line); - if (file) { - displayLines.push({ text: file, kind: "file" }); - inFileHeader = true; - continue; - } - if (line.startsWith("@@")) inFileHeader = false; - if (inFileHeader && (line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ "))) continue; - displayLines.push({ text: line }); - } - return displayLines; -} - -export function diffLineClass(line: DisplayDiffLine): DiffLineClass { - if (line.kind === "file") return "file"; - return diffLineClassFromText(line.text); -} - -export function diffLineClassFromText(text: string): Exclude { - if (text.startsWith("+") && !text.startsWith("+++")) return "added"; - if (text.startsWith("-") && !text.startsWith("---")) return "removed"; - if (text.startsWith("@@")) return "hunk"; - return "context"; -} - -export function displayDiffLineText(text: string, lineClass: DiffLineClass): string { - const displayText = lineClass === "added" || lineClass === "removed" ? text.slice(1) : text; - return displayText || " "; -} - -function filePathFromGitDiffHeader(line: string): string | null { - const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line); - if (!match) return null; - return match[2] ?? null; -} diff --git a/src/shared/diff/render.tsx b/src/shared/ui/diff.tsx similarity index 77% rename from src/shared/diff/render.tsx rename to src/shared/ui/diff.tsx index 8a7ffb0a..41c6f818 100644 --- a/src/shared/diff/render.tsx +++ b/src/shared/ui/diff.tsx @@ -1,10 +1,15 @@ import type { ComponentChild as UiNode } from "preact"; -import { type DiffLineClass, type DisplayDiffLine, diffLineClass, diffLineClassFromText, displayDiffLineText } from "./unified"; - const MAX_INLINE_DIFF_CHARS = 4000; const MAX_INLINE_DIFF_TOKENS = 500; +export interface DiffDisplayLine { + text: string; + kind?: "file"; +} + +type DiffLineClass = "added" | "removed" | "hunk" | "context" | "file"; + interface InlineDiffPart { text: string; changed: boolean; @@ -26,17 +31,61 @@ interface DiffLineView extends RenderDiffLine { type ChangeLineClass = "added" | "removed"; -export function DisplayDiffLines({ lines, className }: { lines: readonly DisplayDiffLine[]; className?: string | undefined }): UiNode { - return ({ text: line.text, className: diffLineClass(line) }))} className={className} />; +export function unifiedDiffDisplayLines(diff: string): DiffDisplayLine[] { + const displayLines: DiffDisplayLine[] = []; + let inFileHeader = false; + for (const line of diff.split("\n")) { + const file = filePathFromGitDiffHeader(line); + if (file) { + displayLines.push({ text: file, kind: "file" }); + inFileHeader = true; + continue; + } + if (line.startsWith("@@")) inFileHeader = false; + if (inFileHeader && (line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ "))) continue; + displayLines.push({ text: line }); + } + return displayLines; } -export function RawDiffLines({ diff, className }: { diff: string; className?: string | undefined }): UiNode { +export function UnifiedDiffView({ diff, className }: { diff: string; className?: string | undefined }): UiNode { + return ; +} + +export function DiffLineList({ lines, className }: { lines: readonly DiffDisplayLine[]; className?: string | undefined }): UiNode { + return ({ text: line.text, className: diffLineClass(line) }))} className={className} />; +} + +export function RawDiffView({ diff, className }: { diff: string; className?: string | undefined }): UiNode { return ( - ({ text: line, className: diffLineClassFromText(line) }))} className={className} /> + ({ text: line, className: diffLineClassFromText(line) }))} className={className} /> ); } -function DiffLines({ lines, className }: { lines: readonly RenderDiffLine[]; className?: string | undefined }): UiNode { +function diffLineClass(line: DiffDisplayLine): DiffLineClass { + if (line.kind === "file") return "file"; + return diffLineClassFromText(line.text); +} + +function diffLineClassFromText(text: string): Exclude { + if (text.startsWith("+") && !text.startsWith("+++")) return "added"; + if (text.startsWith("-") && !text.startsWith("---")) return "removed"; + if (text.startsWith("@@")) return "hunk"; + return "context"; +} + +function displayDiffLineText(text: string, lineClass: DiffLineClass): string { + const displayText = lineClass === "added" || lineClass === "removed" ? text.slice(1) : text; + return displayText || " "; +} + +function filePathFromGitDiffHeader(line: string): string | null { + const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line); + if (!match) return null; + return match[2] ?? null; +} + +function DiffLineFrame({ lines, className }: { lines: readonly RenderDiffLine[]; className?: string | undefined }): UiNode { const preClassName = ["codex-panel-diff", className].filter(Boolean).join(" "); return (
diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs
index 9db274dc..9f399d55 100644
--- a/tests/scripts/grit-policy.test.mjs
+++ b/tests/scripts/grit-policy.test.mjs
@@ -393,6 +393,61 @@ export type Params = ToolRequestUserInputParams;
     expect(pluginDiagnostics(report, "src/app-server/protocol/server-requests.ts")).toEqual([]);
   });
 
+  it("keeps TSX files in rendering-owned source folders", async () => {
+    const cwd = await tempBiomeWorkspace(["no-misplaced-tsx.grit"]);
+    await writeFile(
+      path.join(cwd, "src/features/chat/application/state/view.tsx"),
+      `
+export const value = 
; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/domain/threads/view.tsx"), + ` +export const value = ; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/ui/message.tsx"), + ` +export const value =
; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/settings/section.tsx"), + ` +export const value =
; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/shared/ui/diff.tsx"), + ` +export const value =
;
+`.trimStart(),
+    );
+
+    const report = biomeLint(
+      [
+        "src/features/chat/application/state/view.tsx",
+        "src/domain/threads/view.tsx",
+        "src/features/chat/ui/message.tsx",
+        "src/settings/section.tsx",
+        "src/shared/ui/diff.tsx",
+      ],
+      cwd,
+    );
+
+    expect(pluginMessages(report, "src/features/chat/application/state/view.tsx")).toEqual([
+      "Keep TSX files in UI, panel, settings, or explicit render bridge modules; non-rendering source should use .ts.",
+    ]);
+    expect(pluginMessages(report, "src/domain/threads/view.tsx")).toEqual([
+      "Keep TSX files in UI, panel, settings, or explicit render bridge modules; non-rendering source should use .ts.",
+    ]);
+    expect(pluginDiagnostics(report, "src/features/chat/ui/message.tsx")).toEqual([]);
+    expect(pluginDiagnostics(report, "src/settings/section.tsx")).toEqual([]);
+    expect(pluginDiagnostics(report, "src/shared/ui/diff.tsx")).toEqual([]);
+  });
+
   it("keeps app-server protocol modules behind app-server and chat ingestion boundaries", async () => {
     const cwd = await tempBiomeWorkspace(["no-app-server-protocol-boundary-imports.grit"]);
     await writeFile(
@@ -792,6 +847,7 @@ async function tempBiomeWorkspace(plugins) {
   await mkdir(path.join(cwd, "src/features/chat/panel"), { recursive: true });
   await mkdir(path.join(cwd, "src/features/chat/panel/surface"), { recursive: true });
   await mkdir(path.join(cwd, "src/features/chat/ui"), { recursive: true });
+  await mkdir(path.join(cwd, "src/settings"), { recursive: true });
   await mkdir(path.join(cwd, "src/domain/threads"), { recursive: true });
   await mkdir(path.join(cwd, "src/app-server/connection"), { recursive: true });
   await mkdir(path.join(cwd, "src/app-server/protocol"), { recursive: true });
diff --git a/tests/shared/diff/unified.test.ts b/tests/shared/ui/diff.test.ts
similarity index 87%
rename from tests/shared/diff/unified.test.ts
rename to tests/shared/ui/diff.test.ts
index 817a3bde..ff2843a8 100644
--- a/tests/shared/diff/unified.test.ts
+++ b/tests/shared/ui/diff.test.ts
@@ -1,11 +1,11 @@
 import { describe, expect, it } from "vitest";
 
-import { displayDiffLines } from "../../../src/shared/diff/unified";
+import { unifiedDiffDisplayLines } from "../../../src/shared/ui/diff";
 
 describe("unified diff display lines", () => {
   it("simplifies git diff file headers for turn diff display", () => {
     expect(
-      displayDiffLines(
+      unifiedDiffDisplayLines(
         "diff --git a/days/2026-05-16.md b/days/2026-05-16.md\nindex 111..222\n--- a/days/2026-05-16.md\n+++ b/days/2026-05-16.md\n@@\n-old\n+new",
       ),
     ).toEqual([{ text: "days/2026-05-16.md", kind: "file" }, { text: "@@" }, { text: "-old" }, { text: "+new" }]);
@@ -13,7 +13,7 @@ describe("unified diff display lines", () => {
 
   it("keeps added-file diffs readable after simplifying headers", () => {
     expect(
-      displayDiffLines(
+      unifiedDiffDisplayLines(
         "diff --git a/new-note.md b/new-note.md\nnew file mode 100644\nindex 0000000..1111111\n--- /dev/null\n+++ b/new-note.md\n@@\n+hello",
       ),
     ).toEqual([{ text: "new-note.md", kind: "file" }, { text: "new file mode 100644" }, { text: "@@" }, { text: "+hello" }]);
@@ -21,7 +21,7 @@ describe("unified diff display lines", () => {
 
   it("keeps body lines that look like file markers after the hunk starts", () => {
     expect(
-      displayDiffLines(
+      unifiedDiffDisplayLines(
         "diff --git a/note.md b/note.md\nindex 111..222\n--- a/note.md\n+++ b/note.md\n@@\n+++ frontmatter\n--- removed marker",
       ),
     ).toEqual([{ text: "note.md", kind: "file" }, { text: "@@" }, { text: "+++ frontmatter" }, { text: "--- removed marker" }]);