Restrict TSX rendering source placement

This commit is contained in:
murashit 2026-06-26 10:06:02 +09:00
parent 5323337d84
commit 3f62afa94f
10 changed files with 157 additions and 92 deletions

View file

@ -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"]

View file

@ -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.

View file

@ -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")
}

View file

@ -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 (
<OutputSection title={section.title} className="codex-panel-diff-file">
<DiffLines diff={section.diff} />
<RawDiffView diff={section.diff} />
</OutputSection>
);
}
@ -139,7 +139,3 @@ function OutputSection({ title, className, children }: { title: string; classNam
</div>
);
}
function DiffLines({ diff }: { diff: string }): UiNode {
return <RawDiffLines diff={diff} />;
}

View file

@ -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({
<>
<TurnDiffHeader state={state} copyDiff={actions.copyDiff ?? null} />
{state.files.length > 0 ? <ChangedFiles files={state.files} /> : null}
<UnifiedDiff diff={state.diff} />
<UnifiedDiffView diff={state.diff} className="codex-panel-chat-turn-diff__diff" />
</>
);
}
@ -86,10 +85,6 @@ function ChangedFiles({ files }: { files: string[] }): UiNode {
);
}
function UnifiedDiff({ diff }: { diff: string }): UiNode {
return <DisplayDiffLines lines={displayDiffLines(diff)} className="codex-panel-chat-turn-diff__diff" />;
}
function fileCountLabel(files: string[]): string {
return files.length === 1 ? "Edited 1 file" : `Edited ${String(files.length)} files`;
}

View file

@ -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 (
<DisplayDiffLines
lines={displayDiffLines(diff).filter((line) => line.kind !== "file" && !line.text.startsWith("@@"))}
<DiffLineList
lines={unifiedDiffDisplayLines(diff).filter((line) => line.kind !== "file" && !line.text.startsWith("@@"))}
className="codex-panel-selection-rewrite__diff-body"
/>
);

View file

@ -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<DiffLineClass, "file"> {
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;
}

View file

@ -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 <DiffLines lines={lines.map((line) => ({ 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 <DiffLineList lines={unifiedDiffDisplayLines(diff)} className={className} />;
}
export function DiffLineList({ lines, className }: { lines: readonly DiffDisplayLine[]; className?: string | undefined }): UiNode {
return <DiffLineFrame lines={lines.map((line) => ({ text: line.text, className: diffLineClass(line) }))} className={className} />;
}
export function RawDiffView({ diff, className }: { diff: string; className?: string | undefined }): UiNode {
return (
<DiffLines lines={diff.split("\n").map((line) => ({ text: line, className: diffLineClassFromText(line) }))} className={className} />
<DiffLineFrame lines={diff.split("\n").map((line) => ({ 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<DiffLineClass, "file"> {
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 (
<pre className={preClassName}>

View file

@ -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 = <div />;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/domain/threads/view.tsx"),
`
export const value = <span />;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/features/chat/ui/message.tsx"),
`
export const value = <article />;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/settings/section.tsx"),
`
export const value = <section />;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/shared/ui/diff.tsx"),
`
export const value = <pre />;
`.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 });

View file

@ -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" }]);