mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Restrict TSX rendering source placement
This commit is contained in:
parent
5323337d84
commit
3f62afa94f
10 changed files with 157 additions and 92 deletions
45
biome.jsonc
45
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"]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
5
scripts/lint/no-misplaced-tsx.grit
Normal file
5
scripts/lint/no-misplaced-tsx.grit
Normal 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")
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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" }]);
|
||||
Loading…
Reference in a new issue