feat(web): add markdown preview mode and PR screenshot capture (#461)
This commit is contained in:
@@ -59,4 +59,10 @@ Commit, push, and create a pull request.
|
||||
|
||||
5. **If ready (not draft):** Run `/pr-fixup` to wait for CI checks and CodeRabbit review, fix any failures or valid comments, and push.
|
||||
|
||||
6. **Return the PR URL** when done.
|
||||
6. **PR screenshots:** After creating the PR, check if `apps/web/.pr-assets/manifest.json` exists. If it does:
|
||||
- Read the manifest to list available screenshots/GIFs
|
||||
- Run `npx tsx apps/web/e2e/scripts/upload-pr-assets.ts <PR_NUMBER>` to generate embed markdown
|
||||
- Read `apps/web/.pr-assets/embed.md` and append its contents to the PR body using `gh pr edit <PR_NUMBER> --body "..."`
|
||||
- Tell the user to drag and drop the image files from `.pr-assets/` into the PR description on GitHub for the images to render
|
||||
|
||||
7. **Return the PR URL** when done.
|
||||
|
||||
Generated
+3
@@ -507,6 +507,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.1.18
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
/coverage
|
||||
test-results/
|
||||
playwright-report/
|
||||
.pr-assets/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconArrowBackUp,
|
||||
IconFoldDown,
|
||||
IconFold,
|
||||
IconEye,
|
||||
} from "@tabler/icons-react";
|
||||
import type { RenderHeaderMetadataProps } from "@pierre/diffs";
|
||||
import type { ViewMode } from "@/hooks/use-global-view-mode";
|
||||
@@ -56,6 +57,7 @@ interface DiffHeaderToolbarOptions {
|
||||
viewMode: ViewMode;
|
||||
onToggleViewMode: () => void;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onPreviewMarkdown?: (filePath: string) => void;
|
||||
onRevert?: (filePath: string) => void;
|
||||
expandUnchanged?: boolean;
|
||||
onToggleExpandUnchanged?: () => void;
|
||||
@@ -64,6 +66,7 @@ interface DiffHeaderToolbarOptions {
|
||||
type ToolbarButtonsProps = Omit<DiffHeaderToolbarOptions, "filePath" | "diff"> & {
|
||||
resolvedPath: string;
|
||||
onCopyDiff: () => void;
|
||||
isMarkdownFile: boolean;
|
||||
};
|
||||
|
||||
function DiffHeaderToolbarButtons({
|
||||
@@ -77,6 +80,8 @@ function DiffHeaderToolbarButtons({
|
||||
viewMode,
|
||||
onToggleViewMode,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
isMarkdownFile,
|
||||
}: ToolbarButtonsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -123,6 +128,16 @@ function DiffHeaderToolbarButtons({
|
||||
)}
|
||||
</ToolbarBtn>
|
||||
|
||||
{isMarkdownFile && onPreviewMarkdown && (
|
||||
<ToolbarBtn
|
||||
onClick={() => onPreviewMarkdown(resolvedPath)}
|
||||
tooltip="Preview markdown"
|
||||
className={iconBtn}
|
||||
>
|
||||
<IconEye className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
)}
|
||||
|
||||
{onOpenFile && (
|
||||
<ToolbarBtn onClick={() => onOpenFile(resolvedPath)} tooltip="Edit">
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
@@ -132,6 +147,11 @@ function DiffHeaderToolbarButtons({
|
||||
);
|
||||
}
|
||||
|
||||
function checkIsMarkdown(filePath: string): boolean {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase();
|
||||
return ext === "md" || ext === "mdx";
|
||||
}
|
||||
|
||||
export function useDiffHeaderToolbar(opts: DiffHeaderToolbarOptions) {
|
||||
const {
|
||||
filePath,
|
||||
@@ -141,6 +161,7 @@ export function useDiffHeaderToolbar(opts: DiffHeaderToolbarOptions) {
|
||||
viewMode,
|
||||
onToggleViewMode,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
onRevert,
|
||||
expandUnchanged,
|
||||
onToggleExpandUnchanged,
|
||||
@@ -152,12 +173,14 @@ export function useDiffHeaderToolbar(opts: DiffHeaderToolbarOptions) {
|
||||
return (
|
||||
<DiffHeaderToolbarButtons
|
||||
resolvedPath={resolvedPath}
|
||||
isMarkdownFile={checkIsMarkdown(resolvedPath)}
|
||||
onCopyDiff={() => navigator.clipboard.writeText(diff || "")}
|
||||
wordWrap={wordWrap}
|
||||
onToggleWordWrap={onToggleWordWrap}
|
||||
viewMode={viewMode}
|
||||
onToggleViewMode={onToggleViewMode}
|
||||
onOpenFile={onOpenFile}
|
||||
onPreviewMarkdown={onPreviewMarkdown}
|
||||
onRevert={onRevert}
|
||||
expandUnchanged={expandUnchanged}
|
||||
onToggleExpandUnchanged={onToggleExpandUnchanged}
|
||||
@@ -172,6 +195,7 @@ export function useDiffHeaderToolbar(opts: DiffHeaderToolbarOptions) {
|
||||
viewMode,
|
||||
onToggleViewMode,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
onRevert,
|
||||
expandUnchanged,
|
||||
onToggleExpandUnchanged,
|
||||
|
||||
@@ -23,6 +23,7 @@ interface DiffViewerResolverProps {
|
||||
compact?: boolean;
|
||||
hideHeader?: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onPreviewMarkdown?: (filePath: string) => void;
|
||||
onRevert?: (filePath: string) => void;
|
||||
enableAcceptReject?: boolean;
|
||||
onRevertBlock?: (filePath: string, info: RevertBlockInfo) => void;
|
||||
@@ -50,6 +51,7 @@ export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffVi
|
||||
enableExpansion,
|
||||
expandUnchanged,
|
||||
onToggleExpandUnchanged,
|
||||
onPreviewMarkdown,
|
||||
...rest
|
||||
} = props;
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
|
||||
@@ -30,6 +30,7 @@ interface DiffViewerProps {
|
||||
compact?: boolean;
|
||||
hideHeader?: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onPreviewMarkdown?: (filePath: string) => void;
|
||||
onRevert?: (filePath: string) => void;
|
||||
enableAcceptReject?: boolean;
|
||||
onRevertBlock?: (filePath: string, info: RevertBlockInfo) => Promise<void> | void;
|
||||
@@ -51,6 +52,7 @@ const SCALAR_PROP_KEYS: (keyof DiffViewerProps)[] = [
|
||||
"hideHeader",
|
||||
"className",
|
||||
"onOpenFile",
|
||||
"onPreviewMarkdown",
|
||||
"onRevert",
|
||||
"enableAcceptReject",
|
||||
"onRevertBlock",
|
||||
@@ -126,6 +128,7 @@ export const DiffViewer = memo(function DiffViewer({
|
||||
compact = false,
|
||||
hideHeader = false,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
onRevert,
|
||||
enableAcceptReject = false,
|
||||
onRevertBlock,
|
||||
@@ -193,6 +196,7 @@ export const DiffViewer = memo(function DiffViewer({
|
||||
onLineEnter,
|
||||
onLineLeave,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
onRevert,
|
||||
enableExpansion: canUseExpansion,
|
||||
expandUnchanged,
|
||||
|
||||
@@ -20,6 +20,7 @@ interface FileDiffViewerProps {
|
||||
compact?: boolean;
|
||||
hideHeader?: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onPreviewMarkdown?: (filePath: string) => void;
|
||||
onRevert?: (filePath: string) => void;
|
||||
enableAcceptReject?: boolean;
|
||||
onRevertBlock?: (filePath: string, info: RevertBlockInfo) => void;
|
||||
@@ -57,6 +58,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({
|
||||
compact,
|
||||
hideHeader,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
onRevert,
|
||||
enableAcceptReject,
|
||||
onRevertBlock,
|
||||
@@ -81,6 +83,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({
|
||||
compact={compact}
|
||||
hideHeader={hideHeader}
|
||||
onOpenFile={onOpenFile}
|
||||
onPreviewMarkdown={onPreviewMarkdown}
|
||||
onRevert={onRevert}
|
||||
enableAcceptReject={enableAcceptReject}
|
||||
onRevertBlock={onRevertBlock}
|
||||
|
||||
@@ -67,6 +67,7 @@ type UseDiffOptionsArgs = {
|
||||
onLineEnter: (props: { lineType?: string; lineNumber?: number; annotationSide?: string }) => void;
|
||||
onLineLeave: () => void;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onPreviewMarkdown?: (filePath: string) => void;
|
||||
onRevert?: (filePath: string) => void;
|
||||
/** Enable diff expansion (requires oldLines/newLines in metadata) */
|
||||
enableExpansion?: boolean;
|
||||
@@ -97,6 +98,7 @@ export function useDiffOptions(args: UseDiffOptionsArgs): UseDiffOptionsResult {
|
||||
onLineEnter,
|
||||
onLineLeave,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
onRevert,
|
||||
enableExpansion = false,
|
||||
expansionLineCount = 20,
|
||||
@@ -122,6 +124,7 @@ export function useDiffOptions(args: UseDiffOptionsArgs): UseDiffOptionsResult {
|
||||
viewMode: globalViewMode,
|
||||
onToggleViewMode: toggleViewMode,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
onRevert,
|
||||
expandUnchanged,
|
||||
onToggleExpandUnchanged,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconTextWrapDisabled,
|
||||
IconMessagePlus,
|
||||
IconRefresh,
|
||||
IconEye,
|
||||
} from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { formatDiffStats } from "@/lib/utils/file-diff";
|
||||
@@ -35,6 +36,7 @@ type FileEditorContentProps = {
|
||||
sessionId?: string;
|
||||
worktreePath?: string;
|
||||
enableComments?: boolean;
|
||||
onToggleMarkdownPreview?: () => void;
|
||||
onChange: (newContent: string) => void;
|
||||
onSave: () => void;
|
||||
onReloadFromAgent?: () => void;
|
||||
@@ -170,6 +172,25 @@ function CodeMirrorSaveButton({
|
||||
);
|
||||
}
|
||||
|
||||
function CodeMirrorMarkdownPreviewButton({ onToggle }: { onToggle: () => void }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onToggle}
|
||||
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground"
|
||||
data-testid="markdown-preview-toggle"
|
||||
>
|
||||
<IconEye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Preview markdown</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Toolbar for the CodeMirror code editor. */
|
||||
function CodeMirrorToolbar({
|
||||
path,
|
||||
@@ -186,6 +207,7 @@ function CodeMirrorToolbar({
|
||||
onSave,
|
||||
onReloadFromAgent,
|
||||
onDelete,
|
||||
onToggleMarkdownPreview,
|
||||
}: {
|
||||
path: string;
|
||||
worktreePath?: string;
|
||||
@@ -201,6 +223,7 @@ function CodeMirrorToolbar({
|
||||
onSave: () => void;
|
||||
onReloadFromAgent?: () => void;
|
||||
onDelete?: () => void;
|
||||
onToggleMarkdownPreview?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PanelHeaderBarSplit
|
||||
@@ -221,6 +244,9 @@ function CodeMirrorToolbar({
|
||||
sessionId={sessionId}
|
||||
commentCount={commentCount}
|
||||
/>
|
||||
{onToggleMarkdownPreview && (
|
||||
<CodeMirrorMarkdownPreviewButton onToggle={onToggleMarkdownPreview} />
|
||||
)}
|
||||
<CodeMirrorWrapButton wrapEnabled={wrapEnabled} onToggleWrap={onToggleWrap} />
|
||||
<CodeMirrorReloadButton
|
||||
hasRemoteUpdate={hasRemoteUpdate}
|
||||
@@ -234,6 +260,46 @@ function CodeMirrorToolbar({
|
||||
);
|
||||
}
|
||||
|
||||
type CodeMirrorEditorState = ReturnType<typeof useCodeMirrorEditorState>;
|
||||
|
||||
function CodeMirrorOverlays({ state }: { state: CodeMirrorEditorState }) {
|
||||
return (
|
||||
<>
|
||||
{state.floatingButtonPos && !state.textSelection && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
|
||||
style={{ left: state.floatingButtonPos.x + 8, top: state.floatingButtonPos.y + 8 }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={state.handleFloatingButtonClick}
|
||||
>
|
||||
<IconMessagePlus className="h-3.5 w-3.5" />
|
||||
Comment
|
||||
</Button>
|
||||
)}
|
||||
{state.textSelection && (
|
||||
<EditorCommentPopover
|
||||
selectedText={state.textSelection.text}
|
||||
lineRange={{ start: state.textSelection.startLine, end: state.textSelection.endLine }}
|
||||
position={state.textSelection.position}
|
||||
onSubmit={state.handleCommentSubmit}
|
||||
onSubmitAndRun={state.handleCommentSubmitAndRun}
|
||||
onClose={state.handlePopoverClose}
|
||||
/>
|
||||
)}
|
||||
{state.commentView && (
|
||||
<CommentViewPopover
|
||||
comments={state.commentView.comments}
|
||||
position={state.commentView.position}
|
||||
onDelete={state.handleDeleteComment}
|
||||
onClose={state.handleCommentViewClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeMirrorCodeEditor({
|
||||
path,
|
||||
content,
|
||||
@@ -244,6 +310,7 @@ export function CodeMirrorCodeEditor({
|
||||
sessionId,
|
||||
worktreePath,
|
||||
enableComments = false,
|
||||
onToggleMarkdownPreview,
|
||||
onChange,
|
||||
onSave,
|
||||
onReloadFromAgent,
|
||||
@@ -251,7 +318,6 @@ export function CodeMirrorCodeEditor({
|
||||
}: FileEditorContentProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
|
||||
const state = useCodeMirrorEditorState({
|
||||
path,
|
||||
content,
|
||||
@@ -283,8 +349,8 @@ export function CodeMirrorCodeEditor({
|
||||
onSave={onSave}
|
||||
onReloadFromAgent={onReloadFromAgent}
|
||||
onDelete={onDelete}
|
||||
onToggleMarkdownPreview={onToggleMarkdownPreview}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
@@ -302,40 +368,7 @@ export function CodeMirrorCodeEditor({
|
||||
}}
|
||||
className="h-full overflow-auto text-xs"
|
||||
/>
|
||||
|
||||
{state.floatingButtonPos && !state.textSelection && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
|
||||
style={{ left: state.floatingButtonPos.x + 8, top: state.floatingButtonPos.y + 8 }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={state.handleFloatingButtonClick}
|
||||
>
|
||||
<IconMessagePlus className="h-3.5 w-3.5" />
|
||||
Comment
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{state.textSelection && (
|
||||
<EditorCommentPopover
|
||||
selectedText={state.textSelection.text}
|
||||
lineRange={{ start: state.textSelection.startLine, end: state.textSelection.endLine }}
|
||||
position={state.textSelection.position}
|
||||
onSubmit={state.handleCommentSubmit}
|
||||
onSubmitAndRun={state.handleCommentSubmitAndRun}
|
||||
onClose={state.handlePopoverClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.commentView && (
|
||||
<CommentViewPopover
|
||||
comments={state.commentView.comments}
|
||||
position={state.commentView.position}
|
||||
onDelete={state.handleDeleteComment}
|
||||
onClose={state.handleCommentViewClose}
|
||||
/>
|
||||
)}
|
||||
<CodeMirrorOverlays state={state} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ type MonacoCodeEditorProps = {
|
||||
sessionId?: string;
|
||||
worktreePath?: string;
|
||||
enableComments?: boolean;
|
||||
onToggleMarkdownPreview?: () => void;
|
||||
onChange: (newContent: string) => void;
|
||||
onSave: () => void;
|
||||
onReloadFromAgent?: () => void;
|
||||
@@ -175,6 +176,7 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) {
|
||||
sessionId,
|
||||
worktreePath,
|
||||
enableComments = false,
|
||||
onToggleMarkdownPreview,
|
||||
onSave,
|
||||
onReloadFromAgent,
|
||||
onDelete,
|
||||
@@ -205,6 +207,7 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) {
|
||||
onSave={onSave}
|
||||
onReloadFromAgent={onReloadFromAgent}
|
||||
onDelete={onDelete}
|
||||
onToggleMarkdownPreview={onToggleMarkdownPreview}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<Editor
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconMessagePlus,
|
||||
IconArrowsDiff,
|
||||
IconRefresh,
|
||||
IconEye,
|
||||
} from "@tabler/icons-react";
|
||||
import { formatDiffStats } from "@/lib/utils/file-diff";
|
||||
import { toRelativePath } from "@/lib/utils";
|
||||
@@ -193,6 +194,25 @@ function DeleteButton({ onDelete }: { onDelete?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownPreviewButton({ onTogglePreview }: { onTogglePreview: () => void }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onTogglePreview}
|
||||
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground"
|
||||
data-testid="markdown-preview-toggle"
|
||||
>
|
||||
<IconEye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Preview markdown</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface MonacoEditorToolbarProps {
|
||||
path: string;
|
||||
worktreePath?: string;
|
||||
@@ -214,6 +234,7 @@ interface MonacoEditorToolbarProps {
|
||||
onSave: () => void;
|
||||
onReloadFromAgent?: () => void;
|
||||
onDelete?: () => void;
|
||||
onToggleMarkdownPreview?: () => void;
|
||||
}
|
||||
|
||||
export function MonacoEditorToolbar({
|
||||
@@ -237,6 +258,7 @@ export function MonacoEditorToolbar({
|
||||
onSave,
|
||||
onReloadFromAgent,
|
||||
onDelete,
|
||||
onToggleMarkdownPreview,
|
||||
}: MonacoEditorToolbarProps) {
|
||||
return (
|
||||
<PanelHeaderBarSplit
|
||||
@@ -262,6 +284,9 @@ export function MonacoEditorToolbar({
|
||||
onToggle={onToggleDiffIndicators}
|
||||
/>
|
||||
)}
|
||||
{onToggleMarkdownPreview && (
|
||||
<MarkdownPreviewButton onTogglePreview={onToggleMarkdownPreview} />
|
||||
)}
|
||||
<WrapButton wrapEnabled={wrapEnabled} onToggleWrap={onToggleWrap} />
|
||||
<ReloadFromAgentButton
|
||||
hasRemoteUpdate={hasRemoteUpdate}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconLayoutRows,
|
||||
IconPencil,
|
||||
IconTextWrap,
|
||||
IconEye,
|
||||
} from "@tabler/icons-react";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -29,6 +30,11 @@ import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import type { ReviewFile } from "./types";
|
||||
|
||||
function isMarkdownPath(filePath: string): boolean {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase();
|
||||
return ext === "md" || ext === "mdx";
|
||||
}
|
||||
|
||||
type ReviewDiffListProps = {
|
||||
files: ReviewFile[];
|
||||
reviewedFiles: Set<string>;
|
||||
@@ -40,6 +46,7 @@ type ReviewDiffListProps = {
|
||||
onToggleReviewed: (path: string, reviewed: boolean) => void;
|
||||
onDiscard: (path: string) => void;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onPreviewMarkdown?: (filePath: string) => void;
|
||||
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
|
||||
};
|
||||
|
||||
@@ -54,6 +61,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({
|
||||
onToggleReviewed,
|
||||
onDiscard,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
fileRefs,
|
||||
}: ReviewDiffListProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -75,6 +83,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({
|
||||
onToggleReviewed={onToggleReviewed}
|
||||
onDiscard={onDiscard}
|
||||
onOpenFile={onOpenFile}
|
||||
onPreviewMarkdown={onPreviewMarkdown}
|
||||
sectionRef={fileRefs.get(file.path)}
|
||||
scrollContainer={scrollContainerRef}
|
||||
/>
|
||||
@@ -95,6 +104,7 @@ type FileDiffSectionProps = {
|
||||
onToggleReviewed: (path: string, reviewed: boolean) => void;
|
||||
onDiscard: (path: string) => void;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onPreviewMarkdown?: (filePath: string) => void;
|
||||
sectionRef?: React.RefObject<HTMLDivElement | null>;
|
||||
scrollContainer: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
@@ -179,6 +189,7 @@ type FileDiffToolbarProps = {
|
||||
expandUnchanged: boolean;
|
||||
onDiscard: () => void;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onPreviewMarkdown?: (filePath: string) => void;
|
||||
onToggleExpandUnchanged: () => void;
|
||||
onToggleWordWrap: () => void;
|
||||
};
|
||||
@@ -226,6 +237,7 @@ function FileDiffToolbar(props: FileDiffToolbarProps) {
|
||||
expandUnchanged,
|
||||
onDiscard,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
onToggleExpandUnchanged,
|
||||
onToggleWordWrap,
|
||||
} = props;
|
||||
@@ -266,6 +278,11 @@ function FileDiffToolbar(props: FileDiffToolbarProps) {
|
||||
<IconLayoutColumns className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</ToolbarIconBtn>
|
||||
{onPreviewMarkdown && isMarkdownPath(filePath) && (
|
||||
<ToolbarIconBtn onClick={() => onPreviewMarkdown(filePath)} tooltip="Preview markdown">
|
||||
<IconEye className="h-3.5 w-3.5" />
|
||||
</ToolbarIconBtn>
|
||||
)}
|
||||
{onOpenFile && (
|
||||
<ToolbarIconBtn onClick={() => onOpenFile(filePath)} tooltip="Edit">
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
@@ -296,6 +313,7 @@ type FileDiffHeaderProps = {
|
||||
onCheckboxChange: (checked: boolean | "indeterminate") => void;
|
||||
onDiscard: () => void;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onPreviewMarkdown?: (filePath: string) => void;
|
||||
onToggleCollapse: () => void;
|
||||
onToggleExpandUnchanged: () => void;
|
||||
onToggleWordWrap: () => void;
|
||||
@@ -312,6 +330,7 @@ function FileDiffHeader({
|
||||
onCheckboxChange,
|
||||
onDiscard,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
onToggleCollapse,
|
||||
onToggleExpandUnchanged,
|
||||
onToggleWordWrap,
|
||||
@@ -354,6 +373,7 @@ function FileDiffHeader({
|
||||
expandUnchanged={expandUnchanged}
|
||||
onDiscard={onDiscard}
|
||||
onOpenFile={onOpenFile}
|
||||
onPreviewMarkdown={onPreviewMarkdown}
|
||||
onToggleExpandUnchanged={onToggleExpandUnchanged}
|
||||
onToggleWordWrap={onToggleWordWrap}
|
||||
/>
|
||||
@@ -429,6 +449,7 @@ function FileDiffSection({
|
||||
onToggleReviewed,
|
||||
onDiscard,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
sectionRef,
|
||||
scrollContainer,
|
||||
}: FileDiffSectionProps) {
|
||||
@@ -485,6 +506,7 @@ function FileDiffSection({
|
||||
onCheckboxChange={handleCheckboxChange}
|
||||
onDiscard={handleDiscard}
|
||||
onOpenFile={onOpenFile}
|
||||
onPreviewMarkdown={onPreviewMarkdown}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
onToggleExpandUnchanged={handleToggleExpandUnchanged}
|
||||
onToggleWordWrap={handleToggleWordWrap}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { memo } from "react";
|
||||
import { useEditorProvider } from "@/hooks/use-editor-resolver";
|
||||
import { MonacoCodeEditor } from "@/components/editors/monaco/monaco-code-editor";
|
||||
import { CodeMirrorCodeEditor } from "@/components/editors/codemirror/codemirror-code-editor";
|
||||
import { MarkdownPreviewContent } from "./markdown-preview-content";
|
||||
|
||||
export type FileEditorContentProps = {
|
||||
path: string;
|
||||
@@ -16,6 +17,8 @@ export type FileEditorContentProps = {
|
||||
sessionId?: string;
|
||||
worktreePath?: string;
|
||||
enableComments?: boolean;
|
||||
markdownPreview?: boolean;
|
||||
onToggleMarkdownPreview?: () => void;
|
||||
onChange: (newContent: string) => void;
|
||||
onSave: () => void;
|
||||
onReloadFromAgent?: () => void;
|
||||
@@ -24,6 +27,18 @@ export type FileEditorContentProps = {
|
||||
|
||||
export const FileEditorContent = memo(function FileEditorContent(props: FileEditorContentProps) {
|
||||
const provider = useEditorProvider("code-editor");
|
||||
|
||||
if (props.markdownPreview && props.onToggleMarkdownPreview) {
|
||||
return (
|
||||
<MarkdownPreviewContent
|
||||
path={props.path}
|
||||
content={props.content}
|
||||
worktreePath={props.worktreePath}
|
||||
onTogglePreview={props.onToggleMarkdownPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return provider === "monaco" ? (
|
||||
<MonacoCodeEditor {...props} />
|
||||
) : (
|
||||
|
||||
@@ -16,6 +16,11 @@ import { calculateHash } from "@/lib/utils/file-diff";
|
||||
|
||||
type FileCategory = "image" | "binary" | "text";
|
||||
|
||||
function isMarkdownFile(path: string): boolean {
|
||||
const ext = path.split(".").pop()?.toLowerCase();
|
||||
return ext === "md" || ext === "mdx";
|
||||
}
|
||||
|
||||
function resolveFileCategory(isBinary: boolean, path: string): FileCategory {
|
||||
if (!isBinary) return "text";
|
||||
return getFileCategory(path) === "image" ? "image" : "binary";
|
||||
@@ -92,7 +97,9 @@ export const FileEditorPanel = memo(function FileEditorPanel({ params }: FileEdi
|
||||
const hasRemoteUpdate = useDockviewStore((s) => s.openFiles.get(path)?.hasRemoteUpdate ?? false);
|
||||
const isBinary = useDockviewStore((s) => s.openFiles.get(path)?.isBinary ?? false);
|
||||
const originalContent = useDockviewStore((s) => s.openFiles.get(path)?.originalContent ?? "");
|
||||
const markdownPreview = useDockviewStore((s) => s.openFiles.get(path)?.markdownPreview ?? false);
|
||||
const setFileState = useDockviewStore((s) => s.setFileState);
|
||||
const updateFileState = useDockviewStore((s) => s.updateFileState);
|
||||
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const activeSession = useAppStore((state) =>
|
||||
@@ -111,6 +118,10 @@ export const FileEditorPanel = memo(function FileEditorPanel({ params }: FileEdi
|
||||
const onSave = useCallback(() => saveFile(path), [saveFile, path]);
|
||||
const onReloadFromAgent = useCallback(() => applyRemoteUpdate(path), [applyRemoteUpdate, path]);
|
||||
const onDelete = useCallback(() => deleteFile(path), [deleteFile, path]);
|
||||
const onToggleMarkdownPreview = useCallback(
|
||||
() => updateFileState(path, { markdownPreview: !markdownPreview }),
|
||||
[updateFileState, path, markdownPreview],
|
||||
);
|
||||
|
||||
if (!hasFile) {
|
||||
return (
|
||||
@@ -141,6 +152,8 @@ export const FileEditorPanel = memo(function FileEditorPanel({ params }: FileEdi
|
||||
);
|
||||
}
|
||||
|
||||
const isMarkdown = isMarkdownFile(path);
|
||||
|
||||
return (
|
||||
<PanelRoot>
|
||||
<PanelBody padding={false} scroll={false}>
|
||||
@@ -155,6 +168,8 @@ export const FileEditorPanel = memo(function FileEditorPanel({ params }: FileEdi
|
||||
sessionId={activeSessionId || undefined}
|
||||
worktreePath={worktreePath}
|
||||
enableComments={!!activeSessionId}
|
||||
markdownPreview={isMarkdown ? markdownPreview : false}
|
||||
onToggleMarkdownPreview={isMarkdown ? onToggleMarkdownPreview : undefined}
|
||||
onChange={onChange}
|
||||
onSave={onSave}
|
||||
onReloadFromAgent={onReloadFromAgent}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { IconCode } from "@tabler/icons-react";
|
||||
import { remarkPlugins, markdownComponents } from "@/components/shared/markdown-components";
|
||||
import { toRelativePath } from "@/lib/utils";
|
||||
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
|
||||
|
||||
interface MarkdownPreviewToolbarProps {
|
||||
path: string;
|
||||
worktreePath?: string;
|
||||
onTogglePreview: () => void;
|
||||
}
|
||||
|
||||
function MarkdownPreviewToolbar({
|
||||
path,
|
||||
worktreePath,
|
||||
onTogglePreview,
|
||||
}: MarkdownPreviewToolbarProps) {
|
||||
return (
|
||||
<PanelHeaderBarSplit
|
||||
left={
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
|
||||
<span className="text-xs text-muted-foreground/60">Preview</span>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onTogglePreview}
|
||||
className="h-8 w-8 p-0 cursor-pointer text-foreground"
|
||||
data-testid="markdown-preview-toggle"
|
||||
>
|
||||
<IconCode className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Show code</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownPreviewContentProps {
|
||||
path: string;
|
||||
content: string;
|
||||
worktreePath?: string;
|
||||
onTogglePreview: () => void;
|
||||
}
|
||||
|
||||
export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({
|
||||
path,
|
||||
content,
|
||||
worktreePath,
|
||||
onTogglePreview,
|
||||
}: MarkdownPreviewContentProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col" data-testid="markdown-preview">
|
||||
<MarkdownPreviewToolbar
|
||||
path={path}
|
||||
worktreePath={worktreePath}
|
||||
onTogglePreview={onTogglePreview}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="markdown-body max-w-3xl">
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -325,7 +325,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
onOpenFile: onOpenFileProp,
|
||||
}: TaskChangesPanelProps) {
|
||||
const isArchived = useIsTaskArchived();
|
||||
const { openFile: panelOpenFile } = usePanelActions();
|
||||
const { openFile: panelOpenFile, openFileInMarkdownPreview } = usePanelActions();
|
||||
const handleOpenFile = onOpenFileProp ?? panelOpenFile;
|
||||
|
||||
const {
|
||||
@@ -413,6 +413,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
onToggleReviewed={handleToggleReviewed}
|
||||
onDiscard={handleDiscard}
|
||||
onOpenFile={handleOpenFile}
|
||||
onPreviewMarkdown={openFileInMarkdownPreview}
|
||||
fileRefs={visibleFileRefs}
|
||||
/>
|
||||
</PanelBody>
|
||||
@@ -432,6 +433,7 @@ function ChangesPanelContent({
|
||||
onToggleReviewed,
|
||||
onDiscard,
|
||||
onOpenFile,
|
||||
onPreviewMarkdown,
|
||||
fileRefs,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
@@ -445,6 +447,7 @@ function ChangesPanelContent({
|
||||
onToggleReviewed: (path: string, reviewed: boolean) => void;
|
||||
onDiscard: (path: string) => Promise<void>;
|
||||
onOpenFile: (path: string) => void;
|
||||
onPreviewMarkdown?: (path: string) => void;
|
||||
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
|
||||
}) {
|
||||
if (isLoading && files.length === 0) {
|
||||
@@ -474,6 +477,7 @@ function ChangesPanelContent({
|
||||
onToggleReviewed={onToggleReviewed}
|
||||
onDiscard={onDiscard}
|
||||
onOpenFile={onOpenFile}
|
||||
onPreviewMarkdown={onPreviewMarkdown}
|
||||
fileRefs={fileRefs}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { backendFixture, type BackendContext } from "./backend";
|
||||
import { ApiClient } from "../helpers/api-client";
|
||||
import { PrAssetCapture } from "../helpers/pr-asset-capture";
|
||||
import type { WorkflowStep } from "../../lib/types/http";
|
||||
|
||||
export type SeedData = {
|
||||
@@ -16,7 +17,7 @@ export type SeedData = {
|
||||
};
|
||||
|
||||
export const test = backendFixture.extend<
|
||||
{ testPage: Page },
|
||||
{ testPage: Page; prCapture: PrAssetCapture },
|
||||
{ apiClient: ApiClient; seedData: SeedData }
|
||||
>({
|
||||
// Worker-scoped API client
|
||||
@@ -99,6 +100,15 @@ export const test = backendFixture.extend<
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
|
||||
// PR asset capture — gated behind CAPTURE_PR_ASSETS env var.
|
||||
// When enabled, provides screenshot/recording helpers for PR descriptions.
|
||||
// Destructure in tests that need it: { testPage, prCapture }
|
||||
prCapture: async ({ testPage }, use, testInfo) => {
|
||||
const capture = new PrAssetCapture(testPage, testInfo.file);
|
||||
await use(capture);
|
||||
capture.flush();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type AssetEntry = {
|
||||
name: string;
|
||||
type: "screenshot" | "recording";
|
||||
format: "png" | "gif" | "webm";
|
||||
file: string;
|
||||
test: string;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type AssetManifest = {
|
||||
generated_at: string;
|
||||
assets: AssetEntry[];
|
||||
};
|
||||
|
||||
const DEFAULT_OUTPUT_DIR = path.resolve(__dirname, "../../.pr-assets");
|
||||
const FRAMES_DIR = ".frames";
|
||||
const RECORDING_FPS = 5;
|
||||
const RECORDING_INTERVAL = 1000 / RECORDING_FPS;
|
||||
|
||||
// Ensures the output directory is wiped exactly once per process (per e2e run).
|
||||
let _cleanedForRun = false;
|
||||
|
||||
function cleanOutputDir(outputDir: string): void {
|
||||
if (_cleanedForRun) return;
|
||||
_cleanedForRun = true;
|
||||
if (fs.existsSync(outputDir)) {
|
||||
fs.rmSync(outputDir, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function getTestSlug(testFile: string): string {
|
||||
const base = path.basename(testFile, path.extname(testFile));
|
||||
return sanitizeName(base.replace(/\.spec$/, ""));
|
||||
}
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readManifest(outputDir: string): AssetManifest {
|
||||
const manifestPath = path.join(outputDir, "manifest.json");
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
return JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as AssetManifest;
|
||||
}
|
||||
return { generated_at: new Date().toISOString(), assets: [] };
|
||||
}
|
||||
|
||||
function writeManifest(outputDir: string, manifest: AssetManifest): void {
|
||||
manifest.generated_at = new Date().toISOString();
|
||||
fs.writeFileSync(path.join(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
|
||||
export class PrAssetCapture {
|
||||
private readonly page: Page;
|
||||
private readonly outputDir: string;
|
||||
private readonly enabled: boolean;
|
||||
private readonly testSlug: string;
|
||||
private readonly assets: AssetEntry[] = [];
|
||||
private recordingName: string | null = null;
|
||||
private recordingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private recordingFrameCount = 0;
|
||||
|
||||
constructor(page: Page, testFile: string, opts?: { outputDir?: string }) {
|
||||
this.page = page;
|
||||
this.enabled = !!process.env.CAPTURE_PR_ASSETS;
|
||||
this.outputDir = opts?.outputDir ?? DEFAULT_OUTPUT_DIR;
|
||||
this.testSlug = getTestSlug(testFile);
|
||||
|
||||
if (this.enabled) {
|
||||
cleanOutputDir(this.outputDir);
|
||||
}
|
||||
}
|
||||
|
||||
async screenshot(name: string, opts?: { caption?: string; fullPage?: boolean }): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const safeName = sanitizeName(name);
|
||||
const fileName = `${this.testSlug}--${safeName}.png`;
|
||||
const filePath = path.join(this.outputDir, fileName);
|
||||
|
||||
await this.page.screenshot({
|
||||
path: filePath,
|
||||
fullPage: opts?.fullPage ?? false,
|
||||
});
|
||||
|
||||
this.assets.push({
|
||||
name: safeName,
|
||||
type: "screenshot",
|
||||
format: "png",
|
||||
file: fileName,
|
||||
test: `${this.testSlug}.spec.ts`,
|
||||
caption: opts?.caption,
|
||||
});
|
||||
}
|
||||
|
||||
async startRecording(name: string): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
if (this.recordingName) {
|
||||
await this.stopRecording();
|
||||
}
|
||||
|
||||
const safeName = sanitizeName(name);
|
||||
this.recordingName = safeName;
|
||||
this.recordingFrameCount = 0;
|
||||
|
||||
const framesDir = path.join(this.outputDir, FRAMES_DIR, safeName);
|
||||
ensureDir(framesDir);
|
||||
|
||||
this.recordingInterval = setInterval(async () => {
|
||||
try {
|
||||
const frameNum = String(this.recordingFrameCount++).padStart(4, "0");
|
||||
await this.page.screenshot({
|
||||
path: path.join(framesDir, `frame-${frameNum}.png`),
|
||||
});
|
||||
} catch {
|
||||
// Page may have navigated — ignore frame capture errors
|
||||
}
|
||||
}, RECORDING_INTERVAL);
|
||||
}
|
||||
|
||||
async stopRecording(opts?: { caption?: string }): Promise<void> {
|
||||
if (!this.enabled || !this.recordingName || !this.recordingInterval) return;
|
||||
|
||||
clearInterval(this.recordingInterval);
|
||||
this.recordingInterval = null;
|
||||
|
||||
const safeName = this.recordingName;
|
||||
this.recordingName = null;
|
||||
|
||||
// Take one final frame
|
||||
const framesDir = path.join(this.outputDir, FRAMES_DIR, safeName);
|
||||
try {
|
||||
const frameNum = String(this.recordingFrameCount).padStart(4, "0");
|
||||
await this.page.screenshot({
|
||||
path: path.join(framesDir, `frame-${frameNum}.png`),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Register the asset — conversion happens in the post-processing script
|
||||
const fileName = `${this.testSlug}--${safeName}.gif`;
|
||||
this.assets.push({
|
||||
name: safeName,
|
||||
type: "recording",
|
||||
format: "gif",
|
||||
file: fileName,
|
||||
test: `${this.testSlug}.spec.ts`,
|
||||
caption: opts?.caption,
|
||||
});
|
||||
}
|
||||
|
||||
/** Write captured assets to the manifest. Call once after test completes. */
|
||||
flush(): void {
|
||||
if (!this.enabled || this.assets.length === 0) return;
|
||||
|
||||
// Stop any in-progress recording
|
||||
if (this.recordingInterval) {
|
||||
clearInterval(this.recordingInterval);
|
||||
this.recordingInterval = null;
|
||||
}
|
||||
|
||||
const manifest = readManifest(this.outputDir);
|
||||
// Remove stale entries from this test
|
||||
manifest.assets = manifest.assets.filter((a) => a.test !== `${this.testSlug}.spec.ts`);
|
||||
manifest.assets.push(...this.assets);
|
||||
writeManifest(this.outputDir, manifest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Post-e2e script: converts frame sequences to GIFs using ffmpeg.
|
||||
* Falls back gracefully if ffmpeg is not available.
|
||||
*
|
||||
* Usage: npx tsx e2e/scripts/convert-assets.ts
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { AssetManifest } from "../helpers/pr-asset-capture";
|
||||
|
||||
const ASSETS_DIR = path.resolve(__dirname, "../../.pr-assets");
|
||||
const FRAMES_DIR = path.join(ASSETS_DIR, ".frames");
|
||||
const MANIFEST_PATH = path.join(ASSETS_DIR, "manifest.json");
|
||||
const GIF_WIDTH = 800;
|
||||
const GIF_FPS = 5;
|
||||
|
||||
function hasFfmpeg(): boolean {
|
||||
try {
|
||||
execSync("ffmpeg -version", { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function convertFramesToGif(framesDir: string, outputPath: string): boolean {
|
||||
try {
|
||||
const cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
`-framerate ${GIF_FPS}`,
|
||||
`-i "${path.join(framesDir, "frame-%04d.png")}"`,
|
||||
`-vf "fps=${GIF_FPS},scale=${GIF_WIDTH}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse"`,
|
||||
"-loop 0",
|
||||
`"${outputPath}"`,
|
||||
].join(" ");
|
||||
|
||||
execSync(cmd, { stdio: "ignore" });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to convert frames to GIF: ${outputPath}`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupFrames(recordingName: string): void {
|
||||
const framesPath = path.join(FRAMES_DIR, recordingName);
|
||||
if (fs.existsSync(framesPath)) {
|
||||
fs.rmSync(framesPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
if (!fs.existsSync(MANIFEST_PATH)) {
|
||||
console.log("No manifest.json found — nothing to convert.");
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest: AssetManifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8"));
|
||||
const recordings = manifest.assets.filter((a) => a.type === "recording");
|
||||
|
||||
if (recordings.length === 0) {
|
||||
console.log("No recordings to convert.");
|
||||
return;
|
||||
}
|
||||
|
||||
const ffmpegAvailable = hasFfmpeg();
|
||||
if (!ffmpegAvailable) {
|
||||
console.warn("ffmpeg not found — skipping GIF conversion. Install ffmpeg for GIF support.");
|
||||
// Keep recordings in manifest but mark as unconverted
|
||||
for (const rec of recordings) {
|
||||
const framesDir = path.join(FRAMES_DIR, rec.name);
|
||||
if (!fs.existsSync(framesDir)) continue;
|
||||
// Fall back: use first frame as a static screenshot
|
||||
const firstFrame = path.join(framesDir, "frame-0000.png");
|
||||
if (fs.existsSync(firstFrame)) {
|
||||
const fallbackName = rec.file.replace(".gif", ".png");
|
||||
fs.copyFileSync(firstFrame, path.join(ASSETS_DIR, fallbackName));
|
||||
rec.file = fallbackName;
|
||||
rec.format = "png";
|
||||
rec.type = "screenshot";
|
||||
console.log(` Fallback: ${rec.name} → ${fallbackName} (first frame)`);
|
||||
}
|
||||
cleanupFrames(rec.name);
|
||||
}
|
||||
manifest.generated_at = new Date().toISOString();
|
||||
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
let converted = 0;
|
||||
for (const rec of recordings) {
|
||||
const framesDir = path.join(FRAMES_DIR, rec.name);
|
||||
if (!fs.existsSync(framesDir)) {
|
||||
console.warn(` Frames directory not found for: ${rec.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const outputPath = path.join(ASSETS_DIR, rec.file);
|
||||
console.log(` Converting: ${rec.name} → ${rec.file}`);
|
||||
if (convertFramesToGif(framesDir, outputPath)) {
|
||||
converted++;
|
||||
cleanupFrames(rec.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up .frames dir if empty
|
||||
if (fs.existsSync(FRAMES_DIR)) {
|
||||
const remaining = fs.readdirSync(FRAMES_DIR);
|
||||
if (remaining.length === 0) {
|
||||
fs.rmSync(FRAMES_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
manifest.generated_at = new Date().toISOString();
|
||||
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
|
||||
console.log(`Converted ${converted}/${recordings.length} recordings to GIF.`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Generate a markdown snippet from captured PR assets.
|
||||
*
|
||||
* Reads .pr-assets/manifest.json and produces .pr-assets/embed.md with
|
||||
* placeholder markdown for each asset. The user (or PR skill) then drags
|
||||
* and drops the actual image files into the PR description on GitHub.
|
||||
*
|
||||
* Usage: npx tsx e2e/scripts/upload-pr-assets.ts [<pr-number>]
|
||||
*
|
||||
* Outputs: .pr-assets/embed.md
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { AssetManifest, AssetEntry } from "../helpers/pr-asset-capture";
|
||||
|
||||
const ASSETS_DIR = path.resolve(__dirname, "../../.pr-assets");
|
||||
const MANIFEST_PATH = path.join(ASSETS_DIR, "manifest.json");
|
||||
const EMBED_PATH = path.join(ASSETS_DIR, "embed.md");
|
||||
|
||||
function generateMarkdown(assets: AssetEntry[]): string {
|
||||
if (assets.length === 0) return "";
|
||||
|
||||
const lines: string[] = ["## Screenshots", ""];
|
||||
|
||||
for (const asset of assets) {
|
||||
const caption = asset.caption ?? asset.name.replace(/-/g, " ");
|
||||
if (asset.format === "gif") {
|
||||
lines.push(`### ${caption}`);
|
||||
lines.push("");
|
||||
lines.push(`<!-- Drag and drop .pr-assets/${asset.file} here -->`);
|
||||
lines.push("");
|
||||
} else {
|
||||
lines.push(`**${caption}**`);
|
||||
lines.push("");
|
||||
lines.push(`<!-- Drag and drop .pr-assets/${asset.file} here -->`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
if (!fs.existsSync(MANIFEST_PATH)) {
|
||||
console.log("No manifest.json found — nothing to process.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const manifest: AssetManifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8"));
|
||||
const available = manifest.assets.filter((a) => fs.existsSync(path.join(ASSETS_DIR, a.file)));
|
||||
|
||||
if (available.length === 0) {
|
||||
console.log("No asset files found on disk.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const markdown = generateMarkdown(available);
|
||||
fs.writeFileSync(EMBED_PATH, markdown);
|
||||
|
||||
console.log(`Generated: ${EMBED_PATH}`);
|
||||
console.log(`\nAssets ready (${available.length} files):`);
|
||||
for (const asset of available) {
|
||||
const filePath = path.join(ASSETS_DIR, asset.file);
|
||||
const size = (fs.statSync(filePath).size / 1024).toFixed(1);
|
||||
console.log(` .pr-assets/${asset.file} (${size} KB)`);
|
||||
}
|
||||
console.log(
|
||||
"\nTo add screenshots to the PR, drag and drop the files from .pr-assets/ into the PR description on GitHub.",
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,232 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../fixtures/test-base";
|
||||
import type { SeedData } from "../fixtures/test-base";
|
||||
import type { ApiClient } from "../helpers/api-client";
|
||||
import { SessionPage } from "../pages/session-page";
|
||||
|
||||
const MARKDOWN_CONTENT = `# Hello World
|
||||
|
||||
This is a **markdown** file with some content.
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Item 3
|
||||
|
||||
\`\`\`js
|
||||
console.log("hello");
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
async function seedTaskWithSession(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
title: string,
|
||||
): Promise<{ session: SessionPage; sessionId: string }> {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
title,
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
|
||||
await testPage.goto(`/s/${task.session_id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
return { session, sessionId: task.session_id };
|
||||
}
|
||||
|
||||
/** Open a markdown file from the Files panel and enable preview mode. */
|
||||
async function openFileInPreview(
|
||||
testPage: Page,
|
||||
session: SessionPage,
|
||||
fileName: string,
|
||||
): Promise<void> {
|
||||
await session.clickTab("Files");
|
||||
await expect(session.files).toBeVisible({ timeout: 5_000 });
|
||||
const fileRow = session.files.getByText(fileName);
|
||||
await expect(fileRow).toBeVisible({ timeout: 10_000 });
|
||||
await fileRow.click();
|
||||
|
||||
const editorTab = testPage.locator(`.dv-default-tab:has-text('${fileName}')`);
|
||||
await expect(editorTab).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const previewToggle = testPage.getByTestId("markdown-preview-toggle").first();
|
||||
await expect(previewToggle).toBeVisible({ timeout: 10_000 });
|
||||
await previewToggle.click();
|
||||
|
||||
await expect(testPage.getByTestId("markdown-preview")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
test.describe("Markdown preview", () => {
|
||||
test.describe.configure({ retries: 1, timeout: 120_000 });
|
||||
|
||||
test("toggle markdown preview in file editor", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
prCapture,
|
||||
}) => {
|
||||
// Create a markdown file in the workspace repo before navigating
|
||||
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
||||
const filePath = path.join(repoDir, "readme.md");
|
||||
fs.writeFileSync(filePath, MARKDOWN_CONTENT);
|
||||
|
||||
const { session } = await seedTaskWithSession(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Markdown Preview Test",
|
||||
);
|
||||
|
||||
// Open the Files panel and click on the markdown file
|
||||
await session.clickTab("Files");
|
||||
await expect(session.files).toBeVisible({ timeout: 5_000 });
|
||||
const fileRow = session.files.getByText("readme.md");
|
||||
await expect(fileRow).toBeVisible({ timeout: 10_000 });
|
||||
await fileRow.click();
|
||||
|
||||
// Wait for the file editor tab to appear
|
||||
const editorTab = testPage.locator(".dv-default-tab:has-text('readme.md')");
|
||||
await expect(editorTab).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The preview toggle button should be visible (only for markdown files)
|
||||
const previewToggle = testPage.getByTestId("markdown-preview-toggle").first();
|
||||
await expect(previewToggle).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click to enable markdown preview
|
||||
await previewToggle.click();
|
||||
|
||||
// The markdown preview should be visible with rendered content
|
||||
const preview = testPage.getByTestId("markdown-preview");
|
||||
await expect(preview).toBeVisible({ timeout: 5_000 });
|
||||
// Check that markdown is rendered (heading should be an <h1>)
|
||||
await expect(preview.locator("h1")).toContainText("Hello World");
|
||||
// Check that list items are rendered
|
||||
await expect(preview.locator("li")).toHaveCount(3);
|
||||
|
||||
// Capture screenshot for PR description (only when CAPTURE_PR_ASSETS=true)
|
||||
await prCapture.screenshot("markdown-preview-on", {
|
||||
caption: "Markdown file rendered in preview mode",
|
||||
});
|
||||
|
||||
// Toggle back to code view
|
||||
const codeToggle = testPage.getByTestId("markdown-preview-toggle").first();
|
||||
await codeToggle.click();
|
||||
|
||||
// Preview should be gone
|
||||
await expect(preview).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("markdown preview persists across page refresh", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
// Create a markdown file
|
||||
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
||||
const filePath = path.join(repoDir, "persist-test.md");
|
||||
fs.writeFileSync(filePath, "# Persist Test\n\nSome content here.");
|
||||
|
||||
const { session, sessionId } = await seedTaskWithSession(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Markdown Persist Test",
|
||||
);
|
||||
|
||||
// Open file and enable preview
|
||||
await openFileInPreview(testPage, session, "persist-test.md");
|
||||
await expect(testPage.getByTestId("markdown-preview").locator("h1")).toContainText(
|
||||
"Persist Test",
|
||||
);
|
||||
|
||||
// Verify the markdownPreview flag is in sessionStorage
|
||||
const storedTabs = await testPage.evaluate((sid) => {
|
||||
const raw = window.sessionStorage.getItem(`kandev.openFiles.${sid}`);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}, sessionId);
|
||||
expect(storedTabs).not.toBeNull();
|
||||
const mdTab = storedTabs.find((t: { path: string }) => t.path.endsWith("persist-test.md"));
|
||||
expect(mdTab).toBeTruthy();
|
||||
expect(mdTab.markdownPreview).toBe(true);
|
||||
|
||||
// Brief pause to let the sessionStorage write settle before reload
|
||||
await testPage.waitForTimeout(500);
|
||||
|
||||
// Reload the page — sessionStorage survives same-URL reload.
|
||||
// After reload, the restored file tab becomes active (not the chat),
|
||||
// so we wait for the sidebar instead of the chat panel.
|
||||
await testPage.reload();
|
||||
const sessionAfter = new SessionPage(testPage);
|
||||
await expect(sessionAfter.sidebar).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The file tab should be restored with preview still active
|
||||
const editorTabAfter = testPage.locator(".dv-default-tab:has-text('persist-test.md')");
|
||||
await expect(editorTabAfter).toBeVisible({ timeout: 15_000 });
|
||||
await editorTabAfter.click();
|
||||
|
||||
const previewAfter = testPage.getByTestId("markdown-preview");
|
||||
await expect(previewAfter).toBeVisible({ timeout: 10_000 });
|
||||
await expect(previewAfter.locator("h1")).toContainText("Persist Test");
|
||||
});
|
||||
|
||||
test("open markdown preview from diff toolbar", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
// Create a markdown file as an untracked file — it will show in Changes
|
||||
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
||||
const filePath = path.join(repoDir, "preview-from-diff.md");
|
||||
fs.writeFileSync(filePath, "# Preview From Diff\n\nThis file was created for the diff test.");
|
||||
|
||||
const { session } = await seedTaskWithSession(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Markdown Diff Preview Test",
|
||||
);
|
||||
|
||||
// Open Changes panel — the untracked .md file should appear in the file list.
|
||||
// Click "Changes" tab first, then click the file to open its diff.
|
||||
await session.clickTab("Changes");
|
||||
await expect(session.changes).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Wait for git status to detect the file and show it in the changes list
|
||||
const fileEntry = session.changes.getByText("preview-from-diff.md");
|
||||
await expect(fileEntry).toBeVisible({ timeout: 15_000 });
|
||||
await fileEntry.click();
|
||||
|
||||
// The diff opens as a center panel tab "Diff [preview-from-diff.md]".
|
||||
// Wait for the diff tab and its content to render.
|
||||
const diffTab = testPage.locator(".dv-default-tab:has-text('preview-from-diff.md')");
|
||||
await expect(diffTab).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The diff content should show our markdown text
|
||||
await expect(testPage.getByText("# Preview From Diff")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Click the "Preview markdown" button (eye icon) in the diff header toolbar.
|
||||
// The button is in the center diff panel, not the right-side changes panel.
|
||||
const previewBtn = testPage.getByRole("button", { name: "Preview markdown" }).first();
|
||||
await expect(previewBtn).toBeVisible({ timeout: 10_000 });
|
||||
await previewBtn.click();
|
||||
|
||||
// The file editor should open in preview mode (not code mode).
|
||||
// The markdown preview should be active immediately (no need to toggle).
|
||||
const preview = testPage.getByTestId("markdown-preview");
|
||||
await expect(preview).toBeVisible({ timeout: 10_000 });
|
||||
await expect(preview.locator("h1")).toContainText("Preview From Diff");
|
||||
});
|
||||
});
|
||||
@@ -150,7 +150,7 @@ async function syncOpenFileFromWorkspace({
|
||||
|
||||
type RestoreTabsParams = {
|
||||
activeSessionId: string;
|
||||
savedTabs: Array<{ path: string; name: string }>;
|
||||
savedTabs: Array<{ path: string; name: string; markdownPreview?: boolean }>;
|
||||
savedActiveTab: string;
|
||||
setFileState: (path: string, state: FileEditorState) => void;
|
||||
addFileEditorPanel: (path: string, name: string, quiet?: boolean) => void;
|
||||
@@ -183,6 +183,7 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr
|
||||
originalHash: hash,
|
||||
isDirty: false,
|
||||
isBinary: response.is_binary,
|
||||
markdownPreview: savedTab.markdownPreview,
|
||||
});
|
||||
addFileEditorPanel(savedTab.path, savedTab.name, true);
|
||||
} catch {
|
||||
@@ -242,7 +243,11 @@ function useFileEditorEffects({
|
||||
if (!sessionId || _restorationInProgress || state.isRestoringLayout) return;
|
||||
saveOpenFileTabs(
|
||||
sessionId,
|
||||
Array.from(state.openFiles.values()).map(({ path, name }) => ({ path, name })),
|
||||
Array.from(state.openFiles.values()).map(({ path, name, markdownPreview }) => ({
|
||||
path,
|
||||
name,
|
||||
...(markdownPreview ? { markdownPreview } : {}),
|
||||
})),
|
||||
);
|
||||
});
|
||||
return unsub;
|
||||
@@ -494,7 +499,45 @@ function useFileEditorActions({
|
||||
toast,
|
||||
});
|
||||
|
||||
return { openFile, handleFileChange, saveFile, deleteFileAction, applyRemoteUpdate };
|
||||
const openFileInMarkdownPreview = useCallback(
|
||||
async (filePath: string) => {
|
||||
const client = getWebSocketClient();
|
||||
const currentSessionId = activeSessionIdRef.current;
|
||||
if (!client || !currentSessionId) return;
|
||||
const files = getOpenFiles();
|
||||
if (files.has(filePath)) {
|
||||
updateFileState(filePath, { markdownPreview: true });
|
||||
addFileEditorPanel(filePath, filePath.split("/").pop() || filePath);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response: FileContentResponse = await requestFileContent(
|
||||
client,
|
||||
currentSessionId,
|
||||
filePath,
|
||||
);
|
||||
const state = await buildFileEditorState(filePath, response);
|
||||
setFileState(filePath, { ...state, markdownPreview: true });
|
||||
addFileEditorPanel(filePath, state.name);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to open file",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, setFileState, updateFileState, addFileEditorPanel, toast],
|
||||
);
|
||||
|
||||
return {
|
||||
openFile,
|
||||
openFileInMarkdownPreview,
|
||||
handleFileChange,
|
||||
saveFile,
|
||||
deleteFileAction,
|
||||
applyRemoteUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFileEditors() {
|
||||
@@ -533,19 +576,26 @@ export function useFileEditors() {
|
||||
activeSessionIdRef,
|
||||
gitFileSignaturesRef,
|
||||
});
|
||||
const { openFile, handleFileChange, saveFile, deleteFileAction, applyRemoteUpdate } =
|
||||
useFileEditorActions({
|
||||
activeSessionIdRef,
|
||||
setFileState,
|
||||
updateFileState,
|
||||
addFileEditorPanel,
|
||||
setSavingFiles,
|
||||
toast,
|
||||
});
|
||||
const {
|
||||
openFile,
|
||||
openFileInMarkdownPreview,
|
||||
handleFileChange,
|
||||
saveFile,
|
||||
deleteFileAction,
|
||||
applyRemoteUpdate,
|
||||
} = useFileEditorActions({
|
||||
activeSessionIdRef,
|
||||
setFileState,
|
||||
updateFileState,
|
||||
addFileEditorPanel,
|
||||
setSavingFiles,
|
||||
toast,
|
||||
});
|
||||
|
||||
return {
|
||||
savingFiles,
|
||||
openFile,
|
||||
openFileInMarkdownPreview,
|
||||
saveFile,
|
||||
deleteFile: deleteFileAction,
|
||||
handleFileChange,
|
||||
|
||||
@@ -25,7 +25,8 @@ export function usePanelActions() {
|
||||
const dockAddVscode = useDockviewStore((s) => s.addVscodePanel);
|
||||
|
||||
// File editors (works on desktop through dockview)
|
||||
const { openFile: dockOpenFile } = useFileEditors();
|
||||
const { openFile: dockOpenFile, openFileInMarkdownPreview: dockOpenFileInPreview } =
|
||||
useFileEditors();
|
||||
|
||||
// Mobile/Tablet: layout store
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
@@ -101,6 +102,15 @@ export function usePanelActions() {
|
||||
[isDesktop, dockOpenFile],
|
||||
);
|
||||
|
||||
const openFileInMarkdownPreview = useCallback(
|
||||
(filePath: string) => {
|
||||
if (isDesktop) {
|
||||
dockOpenFileInPreview(filePath);
|
||||
}
|
||||
},
|
||||
[isDesktop, dockOpenFileInPreview],
|
||||
);
|
||||
|
||||
return {
|
||||
addBrowser,
|
||||
addPlan,
|
||||
@@ -109,5 +119,6 @@ export function usePanelActions() {
|
||||
addTerminal,
|
||||
addVscode,
|
||||
openFile,
|
||||
openFileInMarkdownPreview,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -416,6 +416,7 @@ const ACTIVE_TAB_KEY = "kandev.activeTab";
|
||||
export interface StoredFileTab {
|
||||
path: string;
|
||||
name: string;
|
||||
markdownPreview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,7 @@ export type FileEditorState = {
|
||||
hasRemoteUpdate?: boolean;
|
||||
remoteContent?: string;
|
||||
remoteOriginalHash?: string;
|
||||
markdownPreview?: boolean;
|
||||
};
|
||||
|
||||
/** Direction relative to a reference panel or group. */
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test --config e2e/playwright.config.ts",
|
||||
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
|
||||
"e2e:headed": "playwright test --config e2e/playwright.config.ts --headed"
|
||||
"e2e:headed": "playwright test --config e2e/playwright.config.ts --headed",
|
||||
"e2e:capture": "cross-env CAPTURE_PR_ASSETS=true playwright test --config e2e/playwright.config.ts && tsx e2e/scripts/convert-assets.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
@@ -129,6 +130,7 @@
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"happy-dom": "^20.1.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user