Add inline review comments to git diff pane (#530)
* Add inline git diff review comments
Adds a review draft store keyed per diff target, inline gutter + thread
rendering in the diff pane, and a generated review attachment that flows
through the composer and agent providers.
* Strip generated review attachments before saving to draft store
Drafts persist only UserComposerAttachment (no review kind), so filter the
auto-generated review attachment out when seeding the draft or restoring
after a failed auto-submit.
* Fix inline review typecheck and test harness drift
* Unslop inline review feature
- Fix stale-closure bug in composer queueMessage: clear drafts for the
actually-queued attachments, not the latest props
- Drop dead useEffect mirroring initialBody in inline review editor
(parent already remounts via key)
- Restore typed AgentAttachment signature on renderPromptAttachmentAsText;
delete schema redispatch helper and drop null-checks across 5 providers
- Inline single-use review prompt formatters and replace marker switch
with const map
- Drop normalizeComment field-copy redundancy after type guard
- Remove __reviewDraftStoreTestUtils export; test migration via
rehydrate() public path
- Refactor findTarget to return the target line directly (no non-null
assertion at call site)
- Drop redundant isGeneratedReviewAttachment helper; inline kind check
* Update review pill to withUnistyles after rebase
Main's unistyles refactor stripped useUnistyles in favor of withUnistyles +
StyleSheet. Convert ReviewAttachmentPill to use ThemedCircleDot + ICON_SIZE so
the icon picks up the foreground-muted mapping like the other pills.
* Fix lint and typecheck after rebase onto main
Resolve drift after main's withUnistyles refactor:
- Composer complexity: dedup voice?.isVoiceSwitching to drop to 20
- Move per-comment Pressable handlers in git-diff-inline-review into
CommentRow subcomponent so callbacks are stable
- Hoist style/onPress factories to module level (iconButton,
iconButtonDestructive, ghostButton)
- Memoize style arrays in git-diff-pane review wrappers
- Replace inline {} placeholders with useMemo styles
- review-draft-store: extract applyCommentUpdates to avoid map-spread,
include input.key in useMemo deps
- Test fixtures: hoist EMPTY_COMMENTS, add buildReviewActions helper
* Drop GitDiffPane default param to keep complexity at 20
After the rebase, main's `enabled = true` default param combined with the
inline review additions pushed cyclomatic complexity to 21. Drop the
default and resolve `enabled !== false` at the call site (comparison
doesn't count toward complexity).
* Reshape inline review behind @/review module
Drop the `generated: true` attachment flag and consolidate inline-review
logic behind a deep `@/review` module so composer and git-diff-pane stop
hand-composing review primitives. Workspace-scoped review snapshots are
now derived at submit/queue time rather than persisted in the draft
store. Composer review references drop from 51 to 4; git-diff-pane drops
~108 lines and now consumes a controller hook + slot components.
* Fix optimistic review attachments
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
export {
|
||||
buildReviewAttachmentSnapshot,
|
||||
buildReviewDraftKey,
|
||||
buildReviewDraftScopeKey,
|
||||
getReviewDraftComments,
|
||||
resetReviewDraftStore,
|
||||
useActiveReviewDraftMode,
|
||||
useClearReviewDraft,
|
||||
useReviewAttachmentSnapshot,
|
||||
useSetActiveReviewDraftMode,
|
||||
addReviewDraftComment,
|
||||
type BuildReviewDraftKeyInput,
|
||||
type BuildReviewDraftScopeKeyInput,
|
||||
type ReviewDraftCommentInput,
|
||||
type ReviewDraftComment,
|
||||
type ReviewDraftMode,
|
||||
type ReviewDraftSide,
|
||||
} from "./store";
|
||||
|
||||
export {
|
||||
getInlineReviewThreadState,
|
||||
getInlineReviewThreadViewportStyle,
|
||||
getSplitInlineReviewThreadState,
|
||||
groupInlineReviewCommentsByTarget,
|
||||
InlineReviewEditor,
|
||||
InlineReviewGutterCell,
|
||||
InlineReviewThread,
|
||||
isInlineReviewEditorForTarget,
|
||||
SMALL_ACTION_HIT_SLOP,
|
||||
useInlineReviewController,
|
||||
type InlineReviewActions,
|
||||
type InlineReviewEditorState,
|
||||
} from "./surface";
|
||||
@@ -0,0 +1,279 @@
|
||||
import "@/test/window-local-storage";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
import {
|
||||
buildReviewAttachmentSnapshot,
|
||||
buildReviewDraftKey,
|
||||
buildReviewDraftScopeKey,
|
||||
useReviewDraftStore,
|
||||
} from "./store";
|
||||
|
||||
const STORE_STORAGE_KEY = "@paseo:review-draft-store";
|
||||
|
||||
function makeFile(): ParsedDiffFile {
|
||||
return {
|
||||
path: "src/example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "ok",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 40,
|
||||
oldCount: 4,
|
||||
newStart: 40,
|
||||
newCount: 4,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -40,4 +40,4 @@" },
|
||||
{ type: "context", content: "const before = true;" },
|
||||
{ type: "remove", content: "const value = oldValue;" },
|
||||
{ type: "add", content: "const value = newValue;" },
|
||||
{ type: "context", content: "return value;" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildReviewDraftKey", () => {
|
||||
it("scopes by server, workspace-or-cwd, diff mode, base ref, and whitespace mode", () => {
|
||||
const base = buildReviewDraftKey({
|
||||
serverId: " local ",
|
||||
workspaceId: " workspace-1 ",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: " main ",
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
|
||||
expect(base).toBe(
|
||||
"review:server=local:workspace=workspace-1:mode=base:base=main:ignoreWhitespace=false",
|
||||
);
|
||||
expect(
|
||||
buildReviewDraftKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: true,
|
||||
}),
|
||||
).not.toBe(base);
|
||||
expect(
|
||||
buildReviewDraftKey({
|
||||
serverId: "local",
|
||||
workspaceId: null,
|
||||
cwd: "/repo/",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: false,
|
||||
}),
|
||||
).toBe("review:server=local:cwd=%2Frepo:mode=base:base=main:ignoreWhitespace=false");
|
||||
});
|
||||
|
||||
it("builds a mode-free scope key for active review mode sharing", () => {
|
||||
const scope = buildReviewDraftScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
|
||||
expect(scope).toBe(
|
||||
"review:server=local:workspace=workspace-1:base=main:ignoreWhitespace=false",
|
||||
);
|
||||
expect(scope).not.toContain("mode=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("review draft store", () => {
|
||||
it("normalizes persisted active review modes with draft comments", async () => {
|
||||
await AsyncStorage.setItem(
|
||||
STORE_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
version: 0,
|
||||
state: {
|
||||
drafts: {
|
||||
"review:key": [
|
||||
{
|
||||
id: "comment-1",
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Keep me.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
{ id: "bad", filePath: "src/example.ts" },
|
||||
],
|
||||
},
|
||||
activeModesByScope: {
|
||||
"review:scope:base": "base",
|
||||
"review:scope:dirty": "uncommitted",
|
||||
"review:scope:bad": "other",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await useReviewDraftStore.persist.rehydrate();
|
||||
|
||||
const state = useReviewDraftStore.getState();
|
||||
expect(state.activeModesByScope).toEqual({
|
||||
"review:scope:base": "base",
|
||||
"review:scope:dirty": "uncommitted",
|
||||
});
|
||||
expect(state.drafts["review:key"]).toEqual([
|
||||
{
|
||||
id: "comment-1",
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Keep me.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists compact draft comments separately from workspace attachment wire context", () => {
|
||||
const key = buildReviewDraftKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
mode: "uncommitted",
|
||||
baseRef: null,
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
|
||||
useReviewDraftStore.getState().clearReview({ key });
|
||||
useReviewDraftStore.getState().addComment({
|
||||
key,
|
||||
comment: {
|
||||
id: "comment-1",
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Please simplify this.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(useReviewDraftStore.getState().drafts[key]).toEqual([
|
||||
{
|
||||
id: "comment-1",
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Please simplify this.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
useReviewDraftStore.getState().updateComment({
|
||||
key,
|
||||
id: "comment-1",
|
||||
updates: { body: "Please simplify this condition." },
|
||||
updatedAt: "2026-04-21T00:01:00.000Z",
|
||||
});
|
||||
expect(useReviewDraftStore.getState().drafts[key]?.[0]?.body).toBe(
|
||||
"Please simplify this condition.",
|
||||
);
|
||||
|
||||
useReviewDraftStore.getState().deleteComment({ key, id: "comment-1" });
|
||||
expect(useReviewDraftStore.getState().drafts[key]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReviewAttachmentSnapshot", () => {
|
||||
it("builds a bounded workspace review attachment and skips missing targets", () => {
|
||||
const snapshot = buildReviewAttachmentSnapshot({
|
||||
reviewDraftKey: "review:key",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Please simplify this.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "comment-2",
|
||||
filePath: "src/missing.ts",
|
||||
side: "new",
|
||||
lineNumber: 99,
|
||||
body: "This target is stale.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
diffFiles: [makeFile()],
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
kind: "review",
|
||||
reviewDraftKey: "review:key",
|
||||
commentCount: 1,
|
||||
attachment: {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body: "Please simplify this.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,4 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: 40,
|
||||
newLineNumber: 40,
|
||||
type: "context",
|
||||
content: "const before = true;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: 41,
|
||||
newLineNumber: null,
|
||||
type: "remove",
|
||||
content: "const value = oldValue;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: 42,
|
||||
newLineNumber: 42,
|
||||
type: "context",
|
||||
content: "return value;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import { useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
|
||||
const STORE_VERSION = 1;
|
||||
const CONTEXT_RADIUS = 3;
|
||||
const EMPTY_REVIEW_DRAFT_COMMENTS: ReviewDraftComment[] = [];
|
||||
|
||||
type ReviewAttachment = Extract<AgentAttachment, { type: "review" }>;
|
||||
type ReviewAttachmentContextLine = ReviewAttachment["comments"][number]["context"]["targetLine"];
|
||||
type ReviewComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
|
||||
|
||||
export type ReviewDraftMode = "uncommitted" | "base";
|
||||
export type ReviewDraftSide = "old" | "new";
|
||||
|
||||
export interface ReviewDraftComment {
|
||||
id: string;
|
||||
filePath: string;
|
||||
side: ReviewDraftSide;
|
||||
lineNumber: number;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BuildReviewDraftKeyInput {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
mode: ReviewDraftMode;
|
||||
baseRef?: string | null;
|
||||
ignoreWhitespace: boolean;
|
||||
}
|
||||
|
||||
export type BuildReviewDraftScopeKeyInput = Omit<BuildReviewDraftKeyInput, "mode">;
|
||||
|
||||
export interface BuildReviewAttachmentSnapshotInput {
|
||||
reviewDraftKey: string;
|
||||
cwd: string;
|
||||
mode: ReviewDraftMode;
|
||||
baseRef?: string | null;
|
||||
comments: readonly ReviewDraftComment[];
|
||||
diffFiles: readonly ParsedDiffFile[];
|
||||
}
|
||||
|
||||
interface ReviewDraftStoreState {
|
||||
drafts: Record<string, ReviewDraftComment[]>;
|
||||
activeModesByScope: Record<string, ReviewDraftMode>;
|
||||
}
|
||||
|
||||
interface ReviewDraftStoreActions {
|
||||
setActiveMode: (input: { scopeKey: string; mode: ReviewDraftMode }) => void;
|
||||
addComment: (input: { key: string; comment: ReviewDraftCommentInput }) => ReviewDraftComment;
|
||||
updateComment: (input: {
|
||||
key: string;
|
||||
id: string;
|
||||
updates: Partial<Pick<ReviewDraftComment, "body">>;
|
||||
updatedAt?: string;
|
||||
}) => void;
|
||||
deleteComment: (input: { key: string; id: string }) => void;
|
||||
clearReview: (input: { key: string }) => void;
|
||||
}
|
||||
|
||||
export type ReviewDraftCommentInput = Omit<ReviewDraftComment, "id" | "createdAt" | "updatedAt"> &
|
||||
Partial<Pick<ReviewDraftComment, "id" | "createdAt" | "updatedAt">>;
|
||||
|
||||
type ReviewDraftStore = ReviewDraftStoreState & ReviewDraftStoreActions;
|
||||
|
||||
function encodeKeyPart(value: string): string {
|
||||
return encodeURIComponent(value.trim());
|
||||
}
|
||||
|
||||
function normalizeCwd(cwd: string): string {
|
||||
const trimmed = cwd.trim();
|
||||
if (trimmed === "/") {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function normalizeBaseRef(baseRef: string | null | undefined): string {
|
||||
return baseRef?.trim() ?? "";
|
||||
}
|
||||
|
||||
function buildReviewDraftScopeParts(input: BuildReviewDraftScopeKeyInput): string[] {
|
||||
const workspaceId = input.workspaceId?.trim();
|
||||
const workspacePart = workspaceId
|
||||
? `workspace=${encodeKeyPart(workspaceId)}`
|
||||
: `cwd=${encodeKeyPart(normalizeCwd(input.cwd))}`;
|
||||
|
||||
return [
|
||||
"review",
|
||||
`server=${encodeKeyPart(input.serverId)}`,
|
||||
workspacePart,
|
||||
`base=${encodeKeyPart(normalizeBaseRef(input.baseRef))}`,
|
||||
`ignoreWhitespace=${input.ignoreWhitespace ? "true" : "false"}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildReviewDraftScopeKey(input: BuildReviewDraftScopeKeyInput): string {
|
||||
return buildReviewDraftScopeParts(input).join(":");
|
||||
}
|
||||
|
||||
export function buildReviewDraftKey(input: BuildReviewDraftKeyInput): string {
|
||||
const [prefix, serverPart, workspacePart, basePart, whitespacePart] =
|
||||
buildReviewDraftScopeParts(input);
|
||||
return [prefix, serverPart, workspacePart, `mode=${input.mode}`, basePart, whitespacePart].join(
|
||||
":",
|
||||
);
|
||||
}
|
||||
|
||||
function createDraftComment(input: ReviewDraftCommentInput): ReviewDraftComment {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: input.id ?? generateMessageId(),
|
||||
filePath: input.filePath,
|
||||
side: input.side,
|
||||
lineNumber: input.lineNumber,
|
||||
body: input.body,
|
||||
createdAt: input.createdAt ?? now,
|
||||
updatedAt: input.updatedAt ?? input.createdAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCommentUpdates(
|
||||
comment: ReviewDraftComment,
|
||||
targetId: string,
|
||||
updates: Partial<Pick<ReviewDraftComment, "body">>,
|
||||
updatedAt: string,
|
||||
): ReviewDraftComment {
|
||||
if (comment.id !== targetId) {
|
||||
return comment;
|
||||
}
|
||||
return {
|
||||
id: comment.id,
|
||||
filePath: comment.filePath,
|
||||
side: comment.side,
|
||||
lineNumber: comment.lineNumber,
|
||||
body: updates.body ?? comment.body,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePersistedState(state: unknown): ReviewDraftStoreState {
|
||||
if (!state || typeof state !== "object") {
|
||||
return { drafts: {}, activeModesByScope: {} };
|
||||
}
|
||||
const persisted = state as { drafts?: unknown; activeModesByScope?: unknown };
|
||||
const drafts = persisted.drafts;
|
||||
if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) {
|
||||
return { drafts: {}, activeModesByScope: {} };
|
||||
}
|
||||
|
||||
const normalized: Record<string, ReviewDraftComment[]> = {};
|
||||
for (const [key, value] of Object.entries(drafts)) {
|
||||
if (!Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
normalized[key] = value.filter((comment): comment is ReviewDraftComment =>
|
||||
isReviewDraftComment(comment),
|
||||
);
|
||||
}
|
||||
|
||||
const activeModesByScope: Record<string, ReviewDraftMode> = {};
|
||||
const persistedActiveModes = persisted.activeModesByScope;
|
||||
if (
|
||||
persistedActiveModes &&
|
||||
typeof persistedActiveModes === "object" &&
|
||||
!Array.isArray(persistedActiveModes)
|
||||
) {
|
||||
for (const [key, mode] of Object.entries(persistedActiveModes)) {
|
||||
if (mode === "base" || mode === "uncommitted") {
|
||||
activeModesByScope[key] = mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { drafts: normalized, activeModesByScope };
|
||||
}
|
||||
|
||||
function isReviewDraftComment(value: unknown): value is ReviewDraftComment {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof record.id === "string" &&
|
||||
typeof record.filePath === "string" &&
|
||||
(record.side === "old" || record.side === "new") &&
|
||||
typeof record.lineNumber === "number" &&
|
||||
Number.isInteger(record.lineNumber) &&
|
||||
record.lineNumber > 0 &&
|
||||
typeof record.body === "string" &&
|
||||
typeof record.createdAt === "string" &&
|
||||
typeof record.updatedAt === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export const useReviewDraftStore = create<ReviewDraftStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
drafts: {},
|
||||
activeModesByScope: {},
|
||||
setActiveMode: ({ scopeKey, mode }) => {
|
||||
set((state) => {
|
||||
if (state.activeModesByScope[scopeKey] === mode) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
activeModesByScope: {
|
||||
...state.activeModesByScope,
|
||||
[scopeKey]: mode,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
addComment: ({ key, comment }) => {
|
||||
const nextComment = createDraftComment(comment);
|
||||
set((state) => ({
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: [...(state.drafts[key] ?? []), nextComment],
|
||||
},
|
||||
}));
|
||||
return nextComment;
|
||||
},
|
||||
updateComment: ({ key, id, updates, updatedAt }) => {
|
||||
set((state) => {
|
||||
const comments = state.drafts[key] ?? [];
|
||||
if (!comments.some((comment) => comment.id === id)) {
|
||||
return state;
|
||||
}
|
||||
const nextUpdatedAt = updatedAt ?? new Date().toISOString();
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: comments.map((comment) =>
|
||||
applyCommentUpdates(comment, id, updates, nextUpdatedAt),
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
deleteComment: ({ key, id }) => {
|
||||
set((state) => {
|
||||
const comments = state.drafts[key] ?? [];
|
||||
if (!comments.some((comment) => comment.id === id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[key]: comments.filter((comment) => comment.id !== id),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
clearReview: ({ key }) => {
|
||||
set((state) => {
|
||||
if (!state.drafts[key]) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.drafts };
|
||||
delete next[key];
|
||||
return { drafts: next };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "@paseo:review-draft-store",
|
||||
version: STORE_VERSION,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
drafts: state.drafts,
|
||||
activeModesByScope: state.activeModesByScope,
|
||||
}),
|
||||
migrate: async (state) => normalizePersistedState(state),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function toContextLine(line: NumberedDiffLine): ReviewAttachmentContextLine | null {
|
||||
if (line.line.type === "header") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
oldLineNumber: line.oldLineNumber,
|
||||
newLineNumber: line.newLineNumber,
|
||||
type: line.line.type,
|
||||
content: line.line.content,
|
||||
};
|
||||
}
|
||||
|
||||
function findTarget(input: { comment: ReviewDraftComment; diffFiles: readonly ParsedDiffFile[] }): {
|
||||
hunkHeader: string;
|
||||
hunkLines: NumberedDiffLine[];
|
||||
targetIndex: number;
|
||||
targetLine: NumberedDiffLine;
|
||||
} | null {
|
||||
const file = input.diffFiles.find((candidate) => candidate.path === input.comment.filePath);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const hunk of buildNumberedDiffHunks(file)) {
|
||||
const targetIndex = hunk.lines.findIndex((line) => {
|
||||
const cell = input.comment.side === "old" ? line.oldCell : line.newCell;
|
||||
return cell?.lineNumber === input.comment.lineNumber;
|
||||
});
|
||||
const targetLine = hunk.lines[targetIndex];
|
||||
if (targetLine) {
|
||||
return {
|
||||
hunkHeader: hunk.hunkHeader,
|
||||
hunkLines: hunk.lines,
|
||||
targetIndex,
|
||||
targetLine,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildReviewAttachmentSnapshot(
|
||||
input: BuildReviewAttachmentSnapshotInput,
|
||||
): ReviewComposerAttachment | null {
|
||||
const comments: ReviewAttachment["comments"] = [];
|
||||
|
||||
for (const draftComment of input.comments) {
|
||||
const target = findTarget({
|
||||
comment: draftComment,
|
||||
diffFiles: input.diffFiles,
|
||||
});
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetLine = toContextLine(target.targetLine);
|
||||
if (!targetLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contextStart = Math.max(0, target.targetIndex - CONTEXT_RADIUS);
|
||||
const contextEnd = Math.min(target.hunkLines.length, target.targetIndex + CONTEXT_RADIUS + 1);
|
||||
const lines = target.hunkLines
|
||||
.slice(contextStart, contextEnd)
|
||||
.map(toContextLine)
|
||||
.filter((line): line is ReviewAttachmentContextLine => line !== null);
|
||||
|
||||
comments.push({
|
||||
filePath: draftComment.filePath,
|
||||
side: draftComment.side,
|
||||
lineNumber: draftComment.lineNumber,
|
||||
body: draftComment.body,
|
||||
context: {
|
||||
hunkHeader: target.hunkHeader,
|
||||
targetLine,
|
||||
lines,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (comments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachment: ReviewAttachment = {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: input.cwd,
|
||||
mode: input.mode,
|
||||
baseRef: normalizeBaseRef(input.baseRef) || null,
|
||||
comments,
|
||||
};
|
||||
|
||||
return {
|
||||
kind: "review",
|
||||
reviewDraftKey: input.reviewDraftKey,
|
||||
commentCount: comments.length,
|
||||
attachment,
|
||||
};
|
||||
}
|
||||
|
||||
export function useReviewDraftComments(key: string): ReviewDraftComment[] {
|
||||
return useReviewDraftStore((state) => state.drafts[key] ?? EMPTY_REVIEW_DRAFT_COMMENTS);
|
||||
}
|
||||
|
||||
export function useSetActiveReviewDraftMode(): ReviewDraftStoreActions["setActiveMode"] {
|
||||
return useReviewDraftStore((state) => state.setActiveMode);
|
||||
}
|
||||
|
||||
export function useClearReviewDraft(): ReviewDraftStoreActions["clearReview"] {
|
||||
return useReviewDraftStore((state) => state.clearReview);
|
||||
}
|
||||
|
||||
export function addReviewDraftComment(input: {
|
||||
key: string;
|
||||
comment: ReviewDraftCommentInput;
|
||||
}): ReviewDraftComment {
|
||||
return useReviewDraftStore.getState().addComment(input);
|
||||
}
|
||||
|
||||
export function getReviewDraftComments(key: string): ReviewDraftComment[] | undefined {
|
||||
return useReviewDraftStore.getState().drafts[key];
|
||||
}
|
||||
|
||||
export function resetReviewDraftStore(): void {
|
||||
useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} });
|
||||
}
|
||||
|
||||
export function useReviewDraftCommentsForAttachment(input: {
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
}): ReviewDraftComment[] {
|
||||
return useReviewDraftStore((state) =>
|
||||
input.enabled
|
||||
? (state.drafts[input.key] ?? EMPTY_REVIEW_DRAFT_COMMENTS)
|
||||
: EMPTY_REVIEW_DRAFT_COMMENTS,
|
||||
);
|
||||
}
|
||||
|
||||
export function useReviewCommentCount(key: string): number {
|
||||
return useReviewDraftStore((state) => state.drafts[key]?.length ?? 0);
|
||||
}
|
||||
|
||||
export function useActiveReviewDraftMode(input: { scopeKey: string }): ReviewDraftMode | null {
|
||||
return useReviewDraftStore((state) => state.activeModesByScope[input.scopeKey] ?? null);
|
||||
}
|
||||
|
||||
export function useReviewAttachmentSnapshot(input: {
|
||||
key: string;
|
||||
diffFiles: readonly ParsedDiffFile[];
|
||||
cwd: string;
|
||||
mode: ReviewDraftMode;
|
||||
baseRef?: string | null;
|
||||
}): ReviewComposerAttachment | null {
|
||||
const comments = useReviewDraftComments(input.key);
|
||||
return useMemo(
|
||||
() =>
|
||||
buildReviewAttachmentSnapshot({
|
||||
reviewDraftKey: input.key,
|
||||
cwd: input.cwd,
|
||||
mode: input.mode,
|
||||
baseRef: input.baseRef,
|
||||
comments,
|
||||
diffFiles: input.diffFiles,
|
||||
}),
|
||||
[comments, input.key, input.cwd, input.mode, input.baseRef, input.diffFiles],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@/test/window-local-storage";
|
||||
import { act, fireEvent, render, renderHook, cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useReviewDraftStore, type ReviewDraftComment } from "./store";
|
||||
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
import {
|
||||
getInlineReviewThreadState,
|
||||
getInlineReviewThreadViewportStyle,
|
||||
getSplitInlineReviewThreadState,
|
||||
groupInlineReviewCommentsByTarget,
|
||||
InlineReviewEditor,
|
||||
InlineReviewGutterCell,
|
||||
InlineReviewThread,
|
||||
SMALL_ACTION_HIT_SLOP,
|
||||
useInlineReviewController,
|
||||
type InlineReviewActions,
|
||||
} from "./index";
|
||||
|
||||
const { theme, pressablePropsByLabel } = vi.hoisted(() => {
|
||||
Object.assign(globalThis, { __DEV__: false });
|
||||
return {
|
||||
theme: {
|
||||
spacing: { 1: 4, 2: 8, 3: 12 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { base: 4 },
|
||||
fontSize: { xs: 11, sm: 13 },
|
||||
fontWeight: { medium: "500" },
|
||||
lineHeight: { diff: 18 },
|
||||
colors: {
|
||||
accent: "#0a84ff",
|
||||
accentForeground: "#fff",
|
||||
border: "#555",
|
||||
destructive: "#ff453a",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
surface3: "#333",
|
||||
},
|
||||
},
|
||||
pressablePropsByLabel: new Map<string, Record<string, unknown>>(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native", async (importOriginal) => {
|
||||
const ReactModule = await import("react");
|
||||
const actual = await importOriginal<typeof import("react-native")>();
|
||||
return {
|
||||
...actual,
|
||||
Pressable: ({
|
||||
accessibilityLabel,
|
||||
children,
|
||||
onPress,
|
||||
...props
|
||||
}: {
|
||||
accessibilityLabel?: string;
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { hovered: boolean; pressed: boolean }) => React.ReactNode);
|
||||
onPress?: () => void;
|
||||
[key: string]: unknown;
|
||||
}) => {
|
||||
if (accessibilityLabel) {
|
||||
pressablePropsByLabel.set(accessibilityLabel, props);
|
||||
}
|
||||
const resolvedChildren =
|
||||
typeof children === "function" ? children({ hovered: false, pressed: false }) : children;
|
||||
return ReactModule.createElement(
|
||||
"button",
|
||||
{
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": typeof props.testID === "string" ? props.testID : undefined,
|
||||
disabled: props.disabled === true,
|
||||
onClick: onPress,
|
||||
type: "button",
|
||||
},
|
||||
resolvedChildren,
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
withUnistyles: <T,>(component: T) => component,
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isNative: false,
|
||||
isWeb: true,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
Check: createIcon("Check"),
|
||||
CircleDot: createIcon("CircleDot"),
|
||||
MessageCircle: createIcon("MessageCircle"),
|
||||
Pencil: createIcon("Pencil"),
|
||||
Trash2: createIcon("Trash2"),
|
||||
X: createIcon("X"),
|
||||
};
|
||||
});
|
||||
|
||||
function target(overrides: Partial<ReviewableDiffTarget> = {}): ReviewableDiffTarget {
|
||||
return {
|
||||
filePath: "src/example.ts",
|
||||
hunkHeader: "@@ -1,2 +1,2 @@",
|
||||
hunkIndex: 0,
|
||||
lineIndex: 2,
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 2,
|
||||
side: "new",
|
||||
lineNumber: 2,
|
||||
lineType: "add",
|
||||
content: "const value = next;",
|
||||
...overrides,
|
||||
key: buildReviewableDiffTargetKey({
|
||||
filePath: overrides.filePath ?? "src/example.ts",
|
||||
side: overrides.side ?? "new",
|
||||
lineNumber: overrides.lineNumber ?? 2,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_COMMENTS: ReviewDraftComment[] = [];
|
||||
|
||||
function buildReviewActions(overrides: Partial<InlineReviewActions> = {}): InlineReviewActions {
|
||||
return {
|
||||
commentsByTarget: new Map(),
|
||||
editor: null,
|
||||
showPersistentAction: false,
|
||||
onStartComment: vi.fn(),
|
||||
onEditComment: vi.fn(),
|
||||
onCancelEditor: vi.fn(),
|
||||
onSaveEditor: vi.fn(),
|
||||
onDeleteComment: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function comment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftComment {
|
||||
return {
|
||||
id: "comment-1",
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 2,
|
||||
body: "Please simplify this.",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
updatedAt: "2026-04-21T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useInlineReviewController", () => {
|
||||
beforeEach(() => {
|
||||
useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("owns draft comment add, edit, delete, cancel, and key-change editor reset", () => {
|
||||
const reviewTarget = target();
|
||||
const firstKey = "review:key-1";
|
||||
const secondKey = "review:key-2";
|
||||
const { result, rerender } = renderHook(
|
||||
({ reviewDraftKey }) =>
|
||||
useInlineReviewController({ reviewDraftKey, showPersistentAction: true }),
|
||||
{ initialProps: { reviewDraftKey: firstKey } },
|
||||
);
|
||||
|
||||
expect(result.current.showPersistentAction).toBe(true);
|
||||
|
||||
act(() => result.current.onStartComment(reviewTarget));
|
||||
expect(result.current.editor).toEqual({ target: reviewTarget, commentId: null, body: "" });
|
||||
|
||||
act(() => result.current.onSaveEditor(" first comment "));
|
||||
const savedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
|
||||
expect(savedComment).toMatchObject({
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 2,
|
||||
body: "first comment",
|
||||
});
|
||||
expect(result.current.editor).toBeNull();
|
||||
expect(result.current.commentsByTarget.get(reviewTarget.key)?.[0]).toMatchObject({
|
||||
body: "first comment",
|
||||
});
|
||||
|
||||
act(() => result.current.onEditComment(reviewTarget, savedComment!));
|
||||
expect(result.current.editor).toEqual({
|
||||
target: reviewTarget,
|
||||
commentId: savedComment?.id,
|
||||
body: "first comment",
|
||||
});
|
||||
|
||||
act(() => result.current.onSaveEditor(" updated comment "));
|
||||
const updatedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
|
||||
expect(updatedComment).toMatchObject({ id: savedComment?.id, body: "updated comment" });
|
||||
|
||||
act(() => result.current.onEditComment(reviewTarget, updatedComment!));
|
||||
act(() => result.current.onDeleteComment(updatedComment!.id));
|
||||
expect(useReviewDraftStore.getState().drafts[firstKey]).toEqual([]);
|
||||
expect(result.current.editor).toBeNull();
|
||||
|
||||
act(() => result.current.onStartComment(reviewTarget));
|
||||
act(() => result.current.onCancelEditor());
|
||||
expect(result.current.editor).toBeNull();
|
||||
|
||||
act(() => result.current.onStartComment(reviewTarget));
|
||||
rerender({ reviewDraftKey: secondKey });
|
||||
expect(result.current.editor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("git diff inline review helpers", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
pressablePropsByLabel.clear();
|
||||
});
|
||||
|
||||
it("maps persisted draft comments to their reviewable diff target", () => {
|
||||
const comments = [
|
||||
comment(),
|
||||
comment({ id: "comment-2", side: "old", lineNumber: 8 }),
|
||||
comment({ id: "comment-3", filePath: "src/other.ts" }),
|
||||
];
|
||||
const commentsByTarget = groupInlineReviewCommentsByTarget(comments);
|
||||
|
||||
expect(commentsByTarget.get("src/example.ts:new:2")).toEqual([comments[0]]);
|
||||
expect(commentsByTarget.get("src/example.ts:old:8")).toEqual([comments[1]]);
|
||||
expect(commentsByTarget.get("src/other.ts:new:2")).toEqual([comments[2]]);
|
||||
expect(
|
||||
getInlineReviewThreadState({
|
||||
reviewTarget: target(),
|
||||
reviewActions: {
|
||||
commentsByTarget,
|
||||
editor: null,
|
||||
showPersistentAction: false,
|
||||
onStartComment: vi.fn(),
|
||||
onEditComment: vi.fn(),
|
||||
onCancelEditor: vi.fn(),
|
||||
onSaveEditor: vi.fn(),
|
||||
onDeleteComment: vi.fn(),
|
||||
},
|
||||
})?.comments,
|
||||
).toEqual([comments[0]]);
|
||||
});
|
||||
|
||||
it("reserves split inline review height from the taller side", () => {
|
||||
const leftTarget = target({ side: "old", lineNumber: 8, oldLineNumber: 8 });
|
||||
const rightTarget = target();
|
||||
const rightComment = comment();
|
||||
const actions: InlineReviewActions = {
|
||||
commentsByTarget: groupInlineReviewCommentsByTarget([rightComment]),
|
||||
editor: { target: rightTarget, commentId: null, body: "" },
|
||||
showPersistentAction: false,
|
||||
onStartComment: vi.fn(),
|
||||
onEditComment: vi.fn(),
|
||||
onCancelEditor: vi.fn(),
|
||||
onSaveEditor: vi.fn(),
|
||||
onDeleteComment: vi.fn(),
|
||||
};
|
||||
|
||||
const rowState = getSplitInlineReviewThreadState({
|
||||
left: leftTarget,
|
||||
right: rightTarget,
|
||||
reviewActions: actions,
|
||||
});
|
||||
|
||||
expect(rowState?.left).toBeNull();
|
||||
expect(rowState?.right?.comments).toEqual([rightComment]);
|
||||
expect(rowState?.height).toBe(210);
|
||||
});
|
||||
|
||||
it("pins no-wrap review threads to the visible diff viewport", () => {
|
||||
expect(
|
||||
getInlineReviewThreadViewportStyle({
|
||||
viewportWidth: 320,
|
||||
pinToViewport: true,
|
||||
}),
|
||||
).toEqual([{ position: "sticky", left: 0 }, { width: 320 }]);
|
||||
});
|
||||
|
||||
it("keeps the gutter add-comment target accessible", () => {
|
||||
const onStartComment = vi.fn();
|
||||
const reviewTarget = target();
|
||||
const { getByLabelText } = render(
|
||||
<InlineReviewGutterCell
|
||||
reviewTarget={reviewTarget}
|
||||
comments={EMPTY_COMMENTS}
|
||||
isEditorOpen={false}
|
||||
showPersistentAction={false}
|
||||
onStartComment={onStartComment}
|
||||
>
|
||||
<span>2</span>
|
||||
</InlineReviewGutterCell>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText("Add review comment"));
|
||||
expect(onStartComment).toHaveBeenCalledWith(reviewTarget);
|
||||
expect(pressablePropsByLabel.get("Add review comment")?.hitSlop).toBe(SMALL_ACTION_HIT_SLOP);
|
||||
});
|
||||
|
||||
it("replaces the line number with the message icon in the same gutter cell", () => {
|
||||
const reviewTarget = target();
|
||||
const { container, queryByText } = render(
|
||||
<InlineReviewGutterCell
|
||||
reviewTarget={reviewTarget}
|
||||
comments={EMPTY_COMMENTS}
|
||||
isEditorOpen={false}
|
||||
showPersistentAction
|
||||
onStartComment={vi.fn()}
|
||||
>
|
||||
<span>2</span>
|
||||
</InlineReviewGutterCell>,
|
||||
);
|
||||
|
||||
expect(queryByText("2")).toBeNull();
|
||||
expect(container.querySelector("[data-icon='MessageCircle']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineReviewEditor", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("saves trimmed bodies and cancels without saving", () => {
|
||||
const onCancel = vi.fn();
|
||||
const onSave = vi.fn();
|
||||
const { getByTestId } = render(
|
||||
<InlineReviewEditor
|
||||
initialBody=" initial "
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
testID="editor"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(getByTestId("editor-input"), { target: { value: " updated comment " } });
|
||||
fireEvent.click(getByTestId("editor-save"));
|
||||
expect(onSave).toHaveBeenCalledWith("updated comment");
|
||||
|
||||
fireEvent.click(getByTestId("editor-cancel"));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineReviewThread", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("exposes edit and delete actions for existing comments", () => {
|
||||
const reviewTarget = target();
|
||||
const draftComment = comment();
|
||||
const actions = buildReviewActions({
|
||||
commentsByTarget: groupInlineReviewCommentsByTarget([draftComment]),
|
||||
});
|
||||
|
||||
const { getByTestId, getByText } = render(
|
||||
<InlineReviewThread
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={actions}
|
||||
height={76}
|
||||
testID="thread"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText("Please simplify this.")).toBeTruthy();
|
||||
fireEvent.click(getByTestId("review-comment-edit-comment-1"));
|
||||
expect(actions.onEditComment).toHaveBeenCalledWith(reviewTarget, draftComment);
|
||||
fireEvent.click(getByTestId("review-comment-delete-comment-1"));
|
||||
expect(actions.onDeleteComment).toHaveBeenCalledWith("comment-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MessageCircle, Pencil, Trash2 } from "lucide-react-native";
|
||||
import {
|
||||
Pressable,
|
||||
type PressableStateCallbackType,
|
||||
Text,
|
||||
TextInput,
|
||||
type TextStyle,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
|
||||
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
|
||||
type PressableState = PressableStateCallbackType & { hovered?: boolean };
|
||||
|
||||
function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
||||
return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered];
|
||||
}
|
||||
|
||||
function iconButtonDestructiveStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
||||
return [styles.iconButton, (hovered || pressed) && styles.iconButtonDestructiveHovered];
|
||||
}
|
||||
|
||||
function ghostButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
||||
return [styles.ghostButton, (hovered || pressed) && styles.ghostButtonHovered];
|
||||
}
|
||||
|
||||
export const INLINE_REVIEW_COMMENT_HEIGHT = 72;
|
||||
export const INLINE_REVIEW_EDITOR_HEIGHT = 132;
|
||||
const INLINE_REVIEW_GAP = 6;
|
||||
export const SMALL_ACTION_HIT_SLOP = 8;
|
||||
|
||||
export interface InlineReviewEditorState {
|
||||
target: ReviewableDiffTarget;
|
||||
commentId: string | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface InlineReviewActions {
|
||||
commentsByTarget: ReadonlyMap<string, ReviewDraftComment[]>;
|
||||
editor: InlineReviewEditorState | null;
|
||||
showPersistentAction: boolean;
|
||||
onStartComment: (target: ReviewableDiffTarget) => void;
|
||||
onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void;
|
||||
onCancelEditor: () => void;
|
||||
onSaveEditor: (body: string) => void;
|
||||
onDeleteComment: (id: string) => void;
|
||||
}
|
||||
|
||||
export function groupInlineReviewCommentsByTarget(
|
||||
comments: readonly ReviewDraftComment[],
|
||||
): Map<string, ReviewDraftComment[]> {
|
||||
const grouped = new Map<string, ReviewDraftComment[]>();
|
||||
for (const comment of comments) {
|
||||
const key = buildReviewableDiffTargetKey(comment);
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), comment]);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function useInlineReviewController(input: {
|
||||
reviewDraftKey: string;
|
||||
showPersistentAction: boolean;
|
||||
}): InlineReviewActions {
|
||||
const reviewComments = useReviewDraftComments(input.reviewDraftKey);
|
||||
const commentsByTarget = useMemo(
|
||||
() => groupInlineReviewCommentsByTarget(reviewComments),
|
||||
[reviewComments],
|
||||
);
|
||||
const [editor, setEditor] = useState<InlineReviewEditorState | null>(null);
|
||||
const addComment = useReviewDraftStore((state) => state.addComment);
|
||||
const updateComment = useReviewDraftStore((state) => state.updateComment);
|
||||
const deleteComment = useReviewDraftStore((state) => state.deleteComment);
|
||||
|
||||
useEffect(() => {
|
||||
setEditor(null);
|
||||
}, [input.reviewDraftKey]);
|
||||
|
||||
const handleStartComment = useCallback((target: ReviewableDiffTarget) => {
|
||||
setEditor({ target, commentId: null, body: "" });
|
||||
}, []);
|
||||
|
||||
const handleEditComment = useCallback(
|
||||
(target: ReviewableDiffTarget, comment: ReviewDraftComment) => {
|
||||
setEditor({ target, commentId: comment.id, body: comment.body });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCancelEditor = useCallback(() => {
|
||||
setEditor(null);
|
||||
}, []);
|
||||
|
||||
const handleSaveEditor = useCallback(
|
||||
(body: string) => {
|
||||
const trimmedBody = body.trim();
|
||||
if (!editor || trimmedBody.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor.commentId) {
|
||||
updateComment({
|
||||
key: input.reviewDraftKey,
|
||||
id: editor.commentId,
|
||||
updates: { body: trimmedBody },
|
||||
});
|
||||
} else {
|
||||
addComment({
|
||||
key: input.reviewDraftKey,
|
||||
comment: {
|
||||
filePath: editor.target.filePath,
|
||||
side: editor.target.side,
|
||||
lineNumber: editor.target.lineNumber,
|
||||
body: trimmedBody,
|
||||
},
|
||||
});
|
||||
}
|
||||
setEditor(null);
|
||||
},
|
||||
[addComment, editor, input.reviewDraftKey, updateComment],
|
||||
);
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
(id: string) => {
|
||||
deleteComment({ key: input.reviewDraftKey, id });
|
||||
setEditor((current) => (current?.commentId === id ? null : current));
|
||||
},
|
||||
[deleteComment, input.reviewDraftKey],
|
||||
);
|
||||
|
||||
return useMemo<InlineReviewActions>(
|
||||
() => ({
|
||||
commentsByTarget,
|
||||
editor,
|
||||
showPersistentAction: input.showPersistentAction,
|
||||
onStartComment: handleStartComment,
|
||||
onEditComment: handleEditComment,
|
||||
onCancelEditor: handleCancelEditor,
|
||||
onSaveEditor: handleSaveEditor,
|
||||
onDeleteComment: handleDeleteComment,
|
||||
}),
|
||||
[
|
||||
commentsByTarget,
|
||||
editor,
|
||||
handleCancelEditor,
|
||||
handleDeleteComment,
|
||||
handleEditComment,
|
||||
handleSaveEditor,
|
||||
handleStartComment,
|
||||
input.showPersistentAction,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function isInlineReviewEditorForTarget(
|
||||
editor: InlineReviewEditorState | null,
|
||||
target: ReviewableDiffTarget | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
editor &&
|
||||
target &&
|
||||
buildReviewableDiffTargetKey(editor.target) === buildReviewableDiffTargetKey(target),
|
||||
);
|
||||
}
|
||||
|
||||
export function getInlineReviewThreadState(input: {
|
||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}): {
|
||||
comments: ReviewDraftComment[];
|
||||
hasEditor: boolean;
|
||||
editingCommentId: string | null;
|
||||
height: number;
|
||||
} | null {
|
||||
const { reviewTarget, reviewActions } = input;
|
||||
if (!reviewTarget || !reviewActions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? [];
|
||||
const editorForTarget = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget)
|
||||
? reviewActions.editor
|
||||
: null;
|
||||
const hasEditor = editorForTarget !== null;
|
||||
const editingCommentId = editorForTarget?.commentId ?? null;
|
||||
const editingExisting =
|
||||
editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId);
|
||||
|
||||
const visibleCommentCount = editingExisting ? comments.length - 1 : comments.length;
|
||||
const editorCount = hasEditor ? 1 : 0;
|
||||
const visibleBlockCount = visibleCommentCount + editorCount;
|
||||
if (visibleBlockCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const height =
|
||||
visibleCommentCount * INLINE_REVIEW_COMMENT_HEIGHT +
|
||||
editorCount * INLINE_REVIEW_EDITOR_HEIGHT +
|
||||
Math.max(0, visibleBlockCount - 1) * INLINE_REVIEW_GAP;
|
||||
|
||||
return { comments, hasEditor, editingCommentId, height };
|
||||
}
|
||||
|
||||
export function getSplitInlineReviewThreadState(input: {
|
||||
left: ReviewableDiffTarget | null | undefined;
|
||||
right: ReviewableDiffTarget | null | undefined;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}): {
|
||||
left: ReturnType<typeof getInlineReviewThreadState>;
|
||||
right: ReturnType<typeof getInlineReviewThreadState>;
|
||||
height: number;
|
||||
} | null {
|
||||
const left = getInlineReviewThreadState({
|
||||
reviewTarget: input.left,
|
||||
reviewActions: input.reviewActions,
|
||||
});
|
||||
const right = getInlineReviewThreadState({
|
||||
reviewTarget: input.right,
|
||||
reviewActions: input.reviewActions,
|
||||
});
|
||||
const height = Math.max(left?.height ?? 0, right?.height ?? 0);
|
||||
if (height === 0) {
|
||||
return null;
|
||||
}
|
||||
return { left, right, height };
|
||||
}
|
||||
|
||||
export function InlineReviewGutterCell({
|
||||
children,
|
||||
reviewTarget,
|
||||
comments,
|
||||
isEditorOpen,
|
||||
showPersistentAction,
|
||||
onStartComment,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||
comments: readonly ReviewDraftComment[];
|
||||
isEditorOpen: boolean;
|
||||
showPersistentAction: boolean;
|
||||
onStartComment: (target: ReviewableDiffTarget) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const canComment = Boolean(reviewTarget);
|
||||
const hasComments = comments.length > 0;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (reviewTarget) {
|
||||
onStartComment(reviewTarget);
|
||||
}
|
||||
}, [reviewTarget, onStartComment]);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableState): StyleProp<ViewStyle> => [
|
||||
style,
|
||||
canComment && (hovered || pressed) && styles.gutterHovered,
|
||||
],
|
||||
[style, canComment],
|
||||
);
|
||||
|
||||
const labelStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.gutterLabel, hasComments && styles.gutterLabelActive],
|
||||
[hasComments],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole={canComment ? "button" : undefined}
|
||||
accessibilityLabel={canComment ? "Add review comment" : undefined}
|
||||
hitSlop={canComment ? SMALL_ACTION_HIT_SLOP : undefined}
|
||||
disabled={!canComment}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
>
|
||||
{({ hovered, pressed }) => {
|
||||
const showAction =
|
||||
canComment &&
|
||||
(hovered || pressed || isNative || showPersistentAction || hasComments || isEditorOpen);
|
||||
return (
|
||||
<View style={styles.gutterInner}>
|
||||
<View style={labelStyle}>
|
||||
{showAction ? (
|
||||
<MessageCircle
|
||||
size={13}
|
||||
strokeWidth={hasComments ? 2.25 : 1.75}
|
||||
color={hasComments ? theme.colors.accent : theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineReviewThread({
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
height,
|
||||
viewportWidth,
|
||||
pinToViewport = false,
|
||||
testID,
|
||||
}: {
|
||||
reviewTarget: ReviewableDiffTarget;
|
||||
reviewActions: InlineReviewActions;
|
||||
height: number;
|
||||
viewportWidth?: number;
|
||||
pinToViewport?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? [];
|
||||
const editor = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget)
|
||||
? reviewActions.editor
|
||||
: null;
|
||||
const editingCommentId = editor?.commentId ?? null;
|
||||
const editingExisting =
|
||||
editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId);
|
||||
|
||||
const editorElement = editor ? (
|
||||
<InlineReviewEditor
|
||||
key={editingCommentId ?? "new"}
|
||||
initialBody={editor.body}
|
||||
onCancel={reviewActions.onCancelEditor}
|
||||
onSave={reviewActions.onSaveEditor}
|
||||
testID="inline-review-editor"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
styles.threadContainer,
|
||||
getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport }),
|
||||
{ minHeight: height },
|
||||
],
|
||||
[viewportWidth, pinToViewport, height],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID={testID}>
|
||||
{comments.map((comment) => {
|
||||
if (comment.id === editingCommentId) {
|
||||
return <React.Fragment key={comment.id}>{editorElement}</React.Fragment>;
|
||||
}
|
||||
return (
|
||||
<CommentRow
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
reviewTarget={reviewTarget}
|
||||
onEditComment={reviewActions.onEditComment}
|
||||
onDeleteComment={reviewActions.onDeleteComment}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{editor && !editingExisting ? editorElement : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentRow({
|
||||
comment,
|
||||
reviewTarget,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
}: {
|
||||
comment: ReviewDraftComment;
|
||||
reviewTarget: ReviewableDiffTarget;
|
||||
onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void;
|
||||
onDeleteComment: (id: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const handleEdit = useCallback(
|
||||
() => onEditComment(reviewTarget, comment),
|
||||
[onEditComment, reviewTarget, comment],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
() => onDeleteComment(comment.id),
|
||||
[onDeleteComment, comment.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.commentBlock}>
|
||||
<Text style={styles.commentBody} numberOfLines={2}>
|
||||
{comment.body}
|
||||
</Text>
|
||||
<View style={styles.commentActions}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Edit review comment"
|
||||
testID={`review-comment-edit-${comment.id}`}
|
||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||
onPress={handleEdit}
|
||||
style={iconButtonStyle}
|
||||
>
|
||||
<Pencil size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Delete review comment"
|
||||
testID={`review-comment-delete-${comment.id}`}
|
||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||
onPress={handleDelete}
|
||||
style={iconButtonDestructiveStyle}
|
||||
>
|
||||
<Trash2 size={14} color={theme.colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function getInlineReviewThreadViewportStyle({
|
||||
viewportWidth,
|
||||
pinToViewport,
|
||||
}: {
|
||||
viewportWidth?: number;
|
||||
pinToViewport: boolean;
|
||||
}): StyleProp<ViewStyle> {
|
||||
const widthStyle = viewportWidth && viewportWidth > 0 ? { width: viewportWidth } : null;
|
||||
if (!pinToViewport || !isWeb) {
|
||||
return widthStyle;
|
||||
}
|
||||
const stickyStyle = { position: "sticky", left: 0 } as unknown as ViewStyle;
|
||||
return [stickyStyle, widthStyle];
|
||||
}
|
||||
|
||||
export function InlineReviewEditor({
|
||||
initialBody,
|
||||
onCancel,
|
||||
onSave,
|
||||
testID,
|
||||
}: {
|
||||
initialBody: string;
|
||||
onCancel: () => void;
|
||||
onSave: (body: string) => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const [body, setBody] = useState(initialBody);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const trimmedBody = body.trim();
|
||||
const canSave = trimmedBody.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleFocus = useCallback(() => setIsFocused(true), []);
|
||||
const handleBlur = useCallback(() => setIsFocused(false), []);
|
||||
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
|
||||
|
||||
const inputStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [styles.editorInput, isFocused && styles.editorInputFocused],
|
||||
[isFocused],
|
||||
);
|
||||
|
||||
const saveButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableState): StyleProp<ViewStyle> => [
|
||||
styles.saveButton,
|
||||
!canSave && styles.saveButtonDisabled,
|
||||
canSave && (hovered || pressed) && styles.saveButtonHovered,
|
||||
],
|
||||
[canSave],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.editorBlock} testID={testID}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
accessibilityLabel="Review comment"
|
||||
testID={testID ? `${testID}-input` : undefined}
|
||||
placeholder="Leave a comment"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
multiline
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<View style={styles.editorActions}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel review comment"
|
||||
testID={testID ? `${testID}-cancel` : undefined}
|
||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||
onPress={onCancel}
|
||||
style={ghostButtonStyle}
|
||||
>
|
||||
<Text style={styles.ghostButtonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save review comment"
|
||||
testID={testID ? `${testID}-save` : undefined}
|
||||
hitSlop={SMALL_ACTION_HIT_SLOP}
|
||||
disabled={!canSave}
|
||||
onPress={handleSave}
|
||||
style={saveButtonStyle}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>Save</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
gutterHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
gutterInner: {
|
||||
minHeight: theme.lineHeight.diff,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
gutterLabel: {
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
height: theme.lineHeight.diff,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
gutterLabelActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
threadContainer: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: INLINE_REVIEW_GAP,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
commentBlock: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
commentBody: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
commentActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconButton: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
...(isWeb
|
||||
? {
|
||||
transitionProperty: "background-color",
|
||||
transitionDuration: "120ms",
|
||||
transitionTimingFunction: "ease-in-out",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
iconButtonDestructiveHovered: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
editorBlock: {
|
||||
minHeight: INLINE_REVIEW_EDITOR_HEIGHT,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
editorInput: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
textAlignVertical: "top",
|
||||
...(isWeb
|
||||
? {
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
editorInputFocused: {
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
editorActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
ghostButton: {
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
ghostButtonHovered: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
ghostButtonText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
saveButton: {
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
saveButtonHovered: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
saveButtonText: {
|
||||
color: theme.colors.accentForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user