feat(web): mobile file viewer with desktop parity (#945)
This commit is contained in:
@@ -5,13 +5,15 @@ import { vscodeDark } from "@uiw/codemirror-theme-vscode";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { getCodeMirrorExtensionFromPath } from "@/lib/languages";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FileViewerContentProps = {
|
||||
path: string;
|
||||
content: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FileViewerContent({ path, content }: FileViewerContentProps) {
|
||||
export function FileViewerContent({ path, content, className }: FileViewerContentProps) {
|
||||
const langExt = getCodeMirrorExtensionFromPath(path);
|
||||
const extensions: Extension[] = [EditorView.lineWrapping, EditorView.editable.of(false)];
|
||||
if (langExt) {
|
||||
@@ -31,7 +33,15 @@ export function FileViewerContent({ path, content }: FileViewerContentProps) {
|
||||
highlightActiveLine: false,
|
||||
highlightSelectionMatches: true,
|
||||
}}
|
||||
className="h-full overflow-auto text-xs"
|
||||
className={cn(
|
||||
// Wrapper must have a bounded height so CodeMirror's internal .cm-scroller
|
||||
// scrolls instead of expanding to content height. touch-pan-y on
|
||||
// .cm-scroller is what makes vertical touch scroll work on mobile —
|
||||
// without it, .cm-content captures the gesture for selection.
|
||||
"h-full overflow-hidden text-xs",
|
||||
"[&_.cm-scroller]:touch-pan-y [&_.cm-scroller]:overscroll-contain",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { IconEye } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { PanelBody, PanelHeaderBarSplit, PanelRoot } from "../panel-primitives";
|
||||
import { FileViewerContent } from "../file-viewer-content";
|
||||
import { MarkdownPreviewContent } from "../markdown-preview-content";
|
||||
import { FileImageViewer } from "../file-image-viewer";
|
||||
import { FileBinaryViewer } from "../file-binary-viewer";
|
||||
import { getFileCategory } from "@/lib/utils/file-types";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { OpenFileTab } from "@/lib/types/backend";
|
||||
|
||||
type MobileFileViewerPanelProps = {
|
||||
file: OpenFileTab;
|
||||
sessionId: string | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function isMarkdownFile(path: string): boolean {
|
||||
const ext = path.split(".").pop()?.toLowerCase();
|
||||
return ext === "md" || ext === "mdx";
|
||||
}
|
||||
|
||||
function resolveViewerKind(file: OpenFileTab): "image" | "binary" | "text" {
|
||||
if (!file.isBinary) return "text";
|
||||
return getFileCategory(file.path) === "image" ? "image" : "binary";
|
||||
}
|
||||
|
||||
export function MobileFileViewerPanel({ file, sessionId, onClose }: MobileFileViewerPanelProps) {
|
||||
const activeSession = useAppStore((state) =>
|
||||
sessionId ? (state.taskSessions.items[sessionId] ?? null) : null,
|
||||
);
|
||||
const worktreePath = activeSession?.worktree_path ?? undefined;
|
||||
const viewerKind = useMemo(() => resolveViewerKind(file), [file]);
|
||||
const markdownFile = isMarkdownFile(file.path);
|
||||
|
||||
const [markdownPreview, setMarkdownPreview] = useState(false);
|
||||
const [lastPath, setLastPath] = useState(file.path);
|
||||
const content = file.content;
|
||||
|
||||
// Reset preview mode when the file changes so reopening a markdown file
|
||||
// always starts in editor view, not the previous preview state.
|
||||
// Adjust state during render per React docs recommendation.
|
||||
if (lastPath !== file.path) {
|
||||
setLastPath(file.path);
|
||||
setMarkdownPreview(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelRoot data-testid="mobile-file-viewer-panel">
|
||||
<PanelHeaderBarSplit
|
||||
left={<span className="truncate font-mono text-xs">{file.path}</span>}
|
||||
right={
|
||||
<div className="flex items-center gap-1">
|
||||
{markdownFile && !markdownPreview && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="cursor-pointer px-2"
|
||||
onClick={() => setMarkdownPreview(true)}
|
||||
data-testid="markdown-preview-toggle"
|
||||
aria-label="Open markdown preview"
|
||||
>
|
||||
<IconEye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="cursor-pointer px-2" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PanelBody padding={false} scroll={false} className="overflow-hidden">
|
||||
<div className="flex h-full min-h-0 flex-col" data-testid="mobile-file-viewer-content">
|
||||
{viewerKind === "image" && (
|
||||
<FileImageViewer path={file.path} content={file.content} worktreePath={worktreePath} />
|
||||
)}
|
||||
{viewerKind === "binary" && (
|
||||
<FileBinaryViewer path={file.path} worktreePath={worktreePath} />
|
||||
)}
|
||||
{viewerKind === "text" &&
|
||||
(markdownFile && markdownPreview ? (
|
||||
<MarkdownPreviewContent
|
||||
path={file.path}
|
||||
content={content}
|
||||
worktreePath={worktreePath}
|
||||
onTogglePreview={() => setMarkdownPreview((current) => !current)}
|
||||
/>
|
||||
) : (
|
||||
<FileViewerContent path={file.path} content={content} />
|
||||
))}
|
||||
</div>
|
||||
</PanelBody>
|
||||
</PanelRoot>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { OpenFileTab } from "@/lib/types/backend";
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: vi.fn() }),
|
||||
}));
|
||||
|
||||
const fetchAndOpenFileMock = vi.fn();
|
||||
vi.mock("../file-browser-hooks", () => ({
|
||||
fetchAndOpenFile: (...args: unknown[]) => fetchAndOpenFileMock(...args),
|
||||
}));
|
||||
|
||||
import { useMobilePanelHandlers } from "./session-mobile-layout";
|
||||
|
||||
const MOCK_FILE: OpenFileTab = {
|
||||
path: "src/foo.ts",
|
||||
name: "foo.ts",
|
||||
content: "export const x = 1;",
|
||||
originalContent: "export const x = 1;",
|
||||
originalHash: "abc123",
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
const OTHER_FILE: OpenFileTab = {
|
||||
...MOCK_FILE,
|
||||
path: "src/bar.ts",
|
||||
name: "bar.ts",
|
||||
};
|
||||
|
||||
const CHAT_LINK_PATH = "src/chat-link.ts";
|
||||
|
||||
function renderHandlers(initialSid: string | null = "s1") {
|
||||
const handlePanelChange = vi.fn();
|
||||
const view = renderHook(
|
||||
({ sid }) => useMobilePanelHandlers({ effectiveSessionId: sid, handlePanelChange }),
|
||||
{ initialProps: { sid: initialSid } },
|
||||
);
|
||||
return { handlePanelChange, ...view };
|
||||
}
|
||||
|
||||
describe("useMobilePanelHandlers", () => {
|
||||
beforeEach(() => {
|
||||
fetchAndOpenFileMock.mockReset();
|
||||
});
|
||||
|
||||
it("handleOpenFile sets selectedFile and switches to files panel", () => {
|
||||
const { result, handlePanelChange } = renderHandlers();
|
||||
expect(result.current.selectedFile).toBeNull();
|
||||
|
||||
act(() => result.current.handleOpenFile(MOCK_FILE));
|
||||
|
||||
expect(result.current.selectedFile).toEqual(MOCK_FILE);
|
||||
expect(handlePanelChange).toHaveBeenCalledWith("files");
|
||||
});
|
||||
|
||||
it("handleOpenFileFromChat fetches and opens the viewer panel", () => {
|
||||
const { result, handlePanelChange } = renderHandlers();
|
||||
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
|
||||
|
||||
expect(fetchAndOpenFileMock).toHaveBeenCalledWith(
|
||||
"s1",
|
||||
CHAT_LINK_PATH,
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
const openFile = fetchAndOpenFileMock.mock.calls[0]?.[2] as (file: OpenFileTab) => void;
|
||||
act(() => openFile(MOCK_FILE));
|
||||
|
||||
expect(result.current.selectedFile).toEqual(MOCK_FILE);
|
||||
expect(handlePanelChange).toHaveBeenCalledWith("files");
|
||||
});
|
||||
|
||||
it("handleOpenFileFromChat no-ops when no active session", () => {
|
||||
const { result } = renderHandlers(null);
|
||||
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
|
||||
|
||||
expect(fetchAndOpenFileMock).not.toHaveBeenCalled();
|
||||
expect(result.current.selectedFile).toBeNull();
|
||||
});
|
||||
|
||||
it("handlePanelChangeAndClearSheet clears the viewer when switching panels", () => {
|
||||
const { result, handlePanelChange } = renderHandlers();
|
||||
act(() => result.current.handleOpenFile(MOCK_FILE));
|
||||
expect(result.current.selectedFile).toEqual(MOCK_FILE);
|
||||
|
||||
act(() => result.current.handlePanelChangeAndClearSheet("plan"));
|
||||
|
||||
expect(result.current.selectedFile).toBeNull();
|
||||
expect(handlePanelChange).toHaveBeenCalledWith("plan");
|
||||
});
|
||||
|
||||
it("clears selectedFile when effectiveSessionId changes", () => {
|
||||
const { result, rerender } = renderHandlers();
|
||||
act(() => result.current.handleOpenFile(MOCK_FILE));
|
||||
expect(result.current.selectedFile).toEqual(MOCK_FILE);
|
||||
|
||||
rerender({ sid: "s2" });
|
||||
expect(result.current.selectedFile).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps selectedFile when rerendered with same session", () => {
|
||||
const { result, rerender } = renderHandlers();
|
||||
act(() => result.current.handleOpenFile(MOCK_FILE));
|
||||
rerender({ sid: "s1" });
|
||||
expect(result.current.selectedFile).toEqual(MOCK_FILE);
|
||||
});
|
||||
|
||||
it("rejects stale handleOpenFileFromChat callback after session change", () => {
|
||||
const { result, rerender } = renderHandlers();
|
||||
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
|
||||
|
||||
expect(fetchAndOpenFileMock).toHaveBeenCalledWith(
|
||||
"s1",
|
||||
CHAT_LINK_PATH,
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
// Simulate session switch before the async callback fires
|
||||
rerender({ sid: "s2" });
|
||||
expect(result.current.selectedFile).toBeNull();
|
||||
|
||||
// Invoke the stale callback that was registered for session s1
|
||||
const staleCallback = fetchAndOpenFileMock.mock.calls[0]?.[2] as (file: OpenFileTab) => void;
|
||||
act(() => staleCallback(MOCK_FILE));
|
||||
|
||||
// Should still be null because the callback belongs to the old session
|
||||
expect(result.current.selectedFile).toBeNull();
|
||||
});
|
||||
|
||||
it("latest handleOpenFile call wins", () => {
|
||||
const { result } = renderHandlers();
|
||||
act(() => {
|
||||
result.current.handleOpenFile(MOCK_FILE);
|
||||
result.current.handleOpenFile(OTHER_FILE);
|
||||
});
|
||||
expect(result.current.selectedFile).toEqual(OTHER_FILE);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { SessionMobileTopBar } from "./session-mobile-top-bar";
|
||||
import { SessionMobileBottomNav } from "./session-mobile-bottom-nav";
|
||||
import { SessionTaskSwitcherSheet } from "./session-task-switcher-sheet";
|
||||
import { MobileFileViewerPanel } from "./mobile-file-viewer-panel";
|
||||
import { TaskChatPanel } from "../task-chat-panel";
|
||||
import { TaskPlanPanel } from "../task-plan-panel";
|
||||
import { MobileChangesPanel } from "./mobile-changes-panel";
|
||||
@@ -17,7 +18,10 @@ import { MobileSessionsPicker } from "./mobile-sessions-section";
|
||||
import { SessionPanelContent } from "@kandev/ui/pannel-session";
|
||||
import { useSessionLayoutState } from "@/hooks/use-session-layout-state";
|
||||
import { useVisualViewportOffset } from "@/hooks/use-visual-viewport-offset";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { fetchAndOpenFile } from "../file-browser-hooks";
|
||||
import type { MobileSessionPanel } from "@/lib/state/slices/ui/types";
|
||||
import type { OpenFileTab } from "@/lib/types/backend";
|
||||
|
||||
const TOP_NAV_HEIGHT = "3.5rem";
|
||||
const BOTTOM_NAV_HEIGHT = "3.25rem";
|
||||
@@ -81,10 +85,12 @@ type MobilePanelAreaProps = {
|
||||
activeTaskId: string | null;
|
||||
isPassthroughMode: boolean;
|
||||
effectiveSessionId: string | null;
|
||||
selectedFile: OpenFileTab | null;
|
||||
selectedDiff: { path: string; content?: string } | null;
|
||||
handleOpenFileFromChat: (path: string) => void;
|
||||
handleClearSelectedDiff: () => void;
|
||||
handleOpenFile: (file: { path: string }) => void;
|
||||
handleOpenFile: (file: OpenFileTab) => void;
|
||||
handlePanelChangeAndClearSheet: (panel: MobileSessionPanel) => void;
|
||||
topNavHeight: string;
|
||||
bottomNavHeight: string;
|
||||
};
|
||||
@@ -94,10 +100,12 @@ function MobilePanelArea({
|
||||
activeTaskId,
|
||||
isPassthroughMode,
|
||||
effectiveSessionId,
|
||||
selectedFile,
|
||||
selectedDiff,
|
||||
handleOpenFileFromChat,
|
||||
handleClearSelectedDiff,
|
||||
handleOpenFile,
|
||||
handlePanelChangeAndClearSheet,
|
||||
topNavHeight,
|
||||
bottomNavHeight,
|
||||
}: MobilePanelAreaProps) {
|
||||
@@ -144,7 +152,16 @@ function MobilePanelArea({
|
||||
)}
|
||||
{currentMobilePanel === "files" && (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<TaskFilesPanel onOpenFile={handleOpenFile} />
|
||||
{selectedFile ? (
|
||||
<MobileFileViewerPanel
|
||||
key={selectedFile.path}
|
||||
file={selectedFile}
|
||||
sessionId={effectiveSessionId}
|
||||
onClose={() => handlePanelChangeAndClearSheet("files")}
|
||||
/>
|
||||
) : (
|
||||
<TaskFilesPanel onOpenFile={handleOpenFile} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{currentMobilePanel === "terminal" && (
|
||||
@@ -232,30 +249,71 @@ function MobileReviewDialogMount({
|
||||
);
|
||||
}
|
||||
|
||||
function useMobilePanelHandlers({
|
||||
handleSelectDiff,
|
||||
export function useMobilePanelHandlers({
|
||||
effectiveSessionId,
|
||||
handlePanelChange,
|
||||
}: {
|
||||
handleSelectDiff: (path: string, content?: string) => void;
|
||||
effectiveSessionId: string | null;
|
||||
handlePanelChange: (panel: MobileSessionPanel) => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [selectedFile, setSelectedFile] = useState<OpenFileTab | null>(null);
|
||||
const [trackedSessionId, setTrackedSessionId] = useState<string | null>(effectiveSessionId);
|
||||
const latestRequestIdRef = useRef(0);
|
||||
|
||||
// Reset viewer when switching sessions — adjust state during render per
|
||||
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
||||
if (trackedSessionId !== effectiveSessionId) {
|
||||
setTrackedSessionId(effectiveSessionId);
|
||||
setSelectedFile(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
latestRequestIdRef.current += 1;
|
||||
}, [effectiveSessionId]);
|
||||
|
||||
const handleOpenFileFromChat = useCallback(
|
||||
(path: string) => {
|
||||
handleSelectDiff(path);
|
||||
handlePanelChange("changes");
|
||||
if (!effectiveSessionId) return;
|
||||
const requestId = (latestRequestIdRef.current += 1);
|
||||
void fetchAndOpenFile(
|
||||
effectiveSessionId,
|
||||
path,
|
||||
(file) => {
|
||||
if (requestId !== latestRequestIdRef.current) return;
|
||||
setSelectedFile(file);
|
||||
handlePanelChange("files");
|
||||
},
|
||||
toast,
|
||||
);
|
||||
},
|
||||
[handleSelectDiff, handlePanelChange],
|
||||
[effectiveSessionId, handlePanelChange, toast],
|
||||
);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(file: { path: string }) => {
|
||||
handleSelectDiff(file.path);
|
||||
handlePanelChange("changes");
|
||||
(file: OpenFileTab) => {
|
||||
latestRequestIdRef.current += 1;
|
||||
setSelectedFile(file);
|
||||
handlePanelChange("files");
|
||||
},
|
||||
[handleSelectDiff, handlePanelChange],
|
||||
[handlePanelChange],
|
||||
);
|
||||
|
||||
return { handleOpenFileFromChat, handleOpenFile };
|
||||
const handlePanelChangeAndClearSheet = useCallback(
|
||||
(panel: MobileSessionPanel) => {
|
||||
latestRequestIdRef.current += 1;
|
||||
setSelectedFile(null);
|
||||
handlePanelChange(panel);
|
||||
},
|
||||
[handlePanelChange],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedFile,
|
||||
handleOpenFileFromChat,
|
||||
handleOpenFile,
|
||||
handlePanelChangeAndClearSheet,
|
||||
};
|
||||
}
|
||||
|
||||
export const SessionMobileLayout = memo(function SessionMobileLayout({
|
||||
@@ -273,13 +331,11 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
|
||||
remoteCheckedAt,
|
||||
remoteStatusError,
|
||||
}: SessionMobileLayoutProps) {
|
||||
// Use shared layout state hook
|
||||
const {
|
||||
activeTaskId,
|
||||
effectiveSessionId,
|
||||
isPassthroughMode,
|
||||
selectedDiff,
|
||||
handleSelectDiff,
|
||||
handleClearSelectedDiff,
|
||||
totalChangesCount,
|
||||
hasUnseenPlanUpdate,
|
||||
@@ -292,10 +348,8 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
|
||||
setMobileSessionTaskSwitcherOpen,
|
||||
} = useSessionLayoutState({ sessionId });
|
||||
|
||||
const { handleOpenFileFromChat, handleOpenFile } = useMobilePanelHandlers({
|
||||
handleSelectDiff,
|
||||
handlePanelChange,
|
||||
});
|
||||
const { selectedFile, handleOpenFileFromChat, handleOpenFile, handlePanelChangeAndClearSheet } =
|
||||
useMobilePanelHandlers({ effectiveSessionId, handlePanelChange });
|
||||
|
||||
const review = useReviewDialog(effectiveSessionId);
|
||||
|
||||
@@ -320,21 +374,21 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
|
||||
remoteStatusError={remoteStatusError}
|
||||
/>
|
||||
|
||||
{/* Content Area - fixed height panels that manage their own scrolling */}
|
||||
<MobilePanelArea
|
||||
currentMobilePanel={currentMobilePanel}
|
||||
activeTaskId={activeTaskId}
|
||||
isPassthroughMode={isPassthroughMode}
|
||||
effectiveSessionId={effectiveSessionId}
|
||||
selectedFile={selectedFile}
|
||||
selectedDiff={selectedDiff}
|
||||
handleOpenFileFromChat={handleOpenFileFromChat}
|
||||
handleClearSelectedDiff={handleClearSelectedDiff}
|
||||
handleOpenFile={handleOpenFile}
|
||||
handlePanelChangeAndClearSheet={handlePanelChangeAndClearSheet}
|
||||
topNavHeight={TOP_NAV_HEIGHT}
|
||||
bottomNavHeight={BOTTOM_NAV_HEIGHT}
|
||||
/>
|
||||
|
||||
{/* Terminal key-bar — docks above OS keyboard or bottom nav on the terminal panel */}
|
||||
<MobileTerminalKeybar
|
||||
sessionId={effectiveSessionId ?? null}
|
||||
visible={currentMobilePanel === "terminal"}
|
||||
@@ -344,7 +398,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
|
||||
{/* Fixed Bottom Navigation */}
|
||||
<SessionMobileBottomNav
|
||||
activePanel={currentMobilePanel}
|
||||
onPanelChange={handlePanelChange}
|
||||
onPanelChange={handlePanelChangeAndClearSheet}
|
||||
planBadge={hasUnseenPlanUpdate}
|
||||
changesBadge={totalChangesCount}
|
||||
/>
|
||||
|
||||
@@ -29,13 +29,13 @@ export class GitHelper {
|
||||
throw new Error(`git exec failed after 3 attempts: ${cmd}`);
|
||||
}
|
||||
|
||||
createFile(name: string, content: string) {
|
||||
createFile(name: string, content: string | Buffer) {
|
||||
const filePath = path.join(this.repoDir, name);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
modifyFile(name: string, content: string) {
|
||||
modifyFile(name: string, content: string | Buffer) {
|
||||
this.createFile(name, content);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// Routing: /t/{taskId} (task-keyed). File starts with "mobile-" so it runs on
|
||||
// the mobile-chrome Playwright project (Pixel 5 emulation).
|
||||
//
|
||||
// Regression guard: tapping a file in the Files → All files tab must replace
|
||||
// the Files panel with the file viewer, NOT navigate to the Changes panel.
|
||||
import { type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { BackendContext } from "../../fixtures/backend";
|
||||
import { GitHelper, makeGitEnv, createStandardProfile } from "../../helpers/git-helper";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
function createLongFileContent(lines = 500): string {
|
||||
return Array.from(
|
||||
{ length: lines },
|
||||
(_, index) => `export const line_${index} = "line ${index}";`,
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
async function setupMobileFileViewerTest({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
taskTitle,
|
||||
options,
|
||||
}: {
|
||||
testPage: Page;
|
||||
apiClient: ApiClient;
|
||||
seedData: SeedData;
|
||||
backend: BackendContext;
|
||||
taskTitle: string;
|
||||
options?: {
|
||||
extension?: string;
|
||||
content?: string | Buffer;
|
||||
directory?: string;
|
||||
};
|
||||
}): Promise<{ session: SessionPage; filePath: string }> {
|
||||
const fileExtension = options?.extension ?? "ts";
|
||||
const fileContent = options?.content ?? 'export const greeting = "hello";';
|
||||
const fileName = `viewer-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${fileExtension}`;
|
||||
const filePath = options?.directory ? `${options.directory}/${fileName}` : fileName;
|
||||
const git = new GitHelper(
|
||||
path.join(backend.tmpDir, "repos", "e2e-repo"),
|
||||
makeGitEnv(backend.tmpDir),
|
||||
);
|
||||
git.createFile(filePath, fileContent);
|
||||
git.stageAll();
|
||||
git.commit(`add ${filePath}`);
|
||||
|
||||
const profile = await createStandardProfile(apiClient, `mobile-fv-${Date.now()}`);
|
||||
const task = await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profile.id, {
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 45_000 });
|
||||
return { session, filePath };
|
||||
}
|
||||
|
||||
test.describe("Mobile file viewer panel", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("tapping file in All-files tab opens viewer panel instead of Changes panel", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const { filePath } = await setupMobileFileViewerTest({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
taskTitle: "Mobile FV Open",
|
||||
});
|
||||
|
||||
// Navigate to the Files panel via the bottom nav
|
||||
await testPage.getByRole("button", { name: "Files" }).tap();
|
||||
|
||||
// Wait for the file to appear in the browser tree
|
||||
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
||||
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Tap the file — must open the inline viewer panel, NOT switch to the Changes panel
|
||||
await fileNode.tap();
|
||||
|
||||
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
||||
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
||||
await expect(viewer.getByText(filePath)).toBeVisible();
|
||||
});
|
||||
|
||||
test("closing the viewer panel returns to the files browser", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const { filePath } = await setupMobileFileViewerTest({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
taskTitle: "Mobile FV Close",
|
||||
});
|
||||
|
||||
await testPage.getByRole("button", { name: "Files" }).tap();
|
||||
|
||||
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
||||
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
||||
await fileNode.tap();
|
||||
|
||||
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
||||
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await viewer.getByRole("button", { name: "Close" }).tap();
|
||||
await expect(viewer).not.toBeVisible({ timeout: 5_000 });
|
||||
await expect(fileNode).toBeVisible();
|
||||
});
|
||||
|
||||
test("binary files show preview-unavailable message in viewer panel", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const { filePath } = await setupMobileFileViewerTest({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
taskTitle: "Mobile FV Binary",
|
||||
options: { extension: "bin", content: Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0x10]) },
|
||||
});
|
||||
|
||||
await testPage.getByRole("button", { name: "Files" }).tap();
|
||||
|
||||
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
||||
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
||||
await fileNode.tap();
|
||||
|
||||
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
||||
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
||||
await expect(viewer.getByText("Cannot preview this file")).toBeVisible();
|
||||
await expect(viewer.getByText("Binary file")).toBeVisible();
|
||||
});
|
||||
|
||||
test("viewer panel keeps a visible close action and supports scrolling large files", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const { filePath } = await setupMobileFileViewerTest({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
taskTitle: "Mobile FV Scroll",
|
||||
options: { extension: "ts", content: createLongFileContent(1_000) },
|
||||
});
|
||||
|
||||
await testPage.getByRole("button", { name: "Files" }).tap();
|
||||
|
||||
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
||||
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
||||
await fileNode.tap();
|
||||
|
||||
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
||||
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const closeButton = viewer.getByRole("button", { name: "Close" });
|
||||
await expect(closeButton).toBeVisible();
|
||||
await expect(closeButton).toBeInViewport();
|
||||
|
||||
// CodeMirror's `.cm-scroller` owns the scroll for text files (matches the
|
||||
// desktop editor tab). Verify it's the element that actually scrolls.
|
||||
const cmScrollerLocator = viewer.locator(".cm-scroller");
|
||||
await expect(cmScrollerLocator).toHaveCount(1);
|
||||
const cmScroller = cmScrollerLocator.first();
|
||||
await expect(cmScroller).toBeVisible();
|
||||
|
||||
const metrics = await cmScroller.evaluate((element) => ({
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
touchAction: getComputedStyle(element).touchAction,
|
||||
overflowY: getComputedStyle(element).overflowY,
|
||||
}));
|
||||
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
|
||||
// touch-action must allow vertical pan or mobile touch scroll won't work.
|
||||
expect(metrics.touchAction).toMatch(/pan-y|auto/);
|
||||
expect(metrics.overflowY).toMatch(/auto|scroll/);
|
||||
|
||||
const scrollTopAfterMove = await cmScroller.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.scrollBy(0, 1_400);
|
||||
return element.scrollTop;
|
||||
});
|
||||
expect(scrollTopAfterMove).toBeGreaterThan(0);
|
||||
|
||||
// Real touch swipe: dispatch CDP touch events to exercise the gesture path
|
||||
// that the programmatic scrollBy above bypasses. This is the path the user
|
||||
// actually triggers when finger-scrolling a long file.
|
||||
await cmScroller.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
const box = await cmScroller.boundingBox();
|
||||
if (!box) throw new Error("cm-scroller has no bounding box");
|
||||
const cdp = await testPage.context().newCDPSession(testPage);
|
||||
const centerX = box.x + box.width / 2;
|
||||
const startY = box.y + box.height - 20;
|
||||
const endY = box.y + 20;
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: centerX, y: startY }],
|
||||
});
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const y = startY + ((endY - startY) * i) / 8;
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: centerX, y }],
|
||||
});
|
||||
}
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchEnd",
|
||||
touchPoints: [],
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => cmScroller.evaluate((element) => element.scrollTop), {
|
||||
timeout: 5_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("markdown files can toggle preview in the viewer panel", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const { filePath } = await setupMobileFileViewerTest({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
taskTitle: "Mobile FV Markdown",
|
||||
options: { extension: "md", content: "# Heading\n\nBody text" },
|
||||
});
|
||||
|
||||
await testPage.getByRole("button", { name: "Files" }).tap();
|
||||
|
||||
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
||||
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
||||
await fileNode.tap();
|
||||
|
||||
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
||||
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await viewer.getByTestId("markdown-preview-toggle").tap();
|
||||
await expect(viewer.getByTestId("markdown-preview")).toBeVisible();
|
||||
});
|
||||
|
||||
test("viewer header shows full path for files inside subdirectories", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const directory = `nested-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
const { filePath } = await setupMobileFileViewerTest({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
taskTitle: "Mobile FV Subdir",
|
||||
options: { directory },
|
||||
});
|
||||
|
||||
await testPage.getByRole("button", { name: "Files" }).tap();
|
||||
|
||||
const dirNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${directory}"]`);
|
||||
await expect(dirNode).toBeVisible({ timeout: 15_000 });
|
||||
await dirNode.tap();
|
||||
|
||||
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
||||
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
||||
await fileNode.tap();
|
||||
|
||||
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
||||
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Header must show full relative path, not just the basename.
|
||||
await expect(viewer.getByText(filePath)).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user