Skip to content

Commit 90a81a7

Browse files
authored
Merge pull request #448 from CWNU-Open-Source-Community/Qiyuanqiii/workspace-markdown-preview
feat(files): add safe Markdown source and preview views
2 parents 2511e35 + d6678c2 commit 90a81a7

6 files changed

Lines changed: 222 additions & 8 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Workspace Markdown preview
2+
3+
Captured in Chrome at 1440 × 1000 using the production renderer and the `workbenchjourney` production API harness, with its offline model provider and a disposable project. The harness was restarted after rebuilding because it loads the UI bundle at startup.
4+
5+
Both captures open the same 657-byte `preview-notes.md` sample from Workspace files. It contains headings, a list, a table, a fenced code block, an HTTPS link, and a relative file link. Its returned content digest begins with `6d5357a52d91` in both captures.
6+
7+
- **Before** (`5892e371bea0a20596d9684ecd1778049a143700`, PR #442): the file is available only as numbered Markdown source.
8+
- **After** (this PR): selecting Preview shows formatted content. Source remains the initial view.
9+
- Searching for `PREVIEW_SEARCH_MARKER` while previewing and clicking its result returned to Source, highlighted line 24, and displayed the position notice.
10+
- The preview's HTTPS link retained `target="_blank"` and `rel="noreferrer noopener"`; the relative file link rendered as an inert span.
11+
12+
These are unmodified browser screenshots.
13+
14+
![Before: Markdown source only](before-markdown-source.png)
15+
16+
![After: formatted Markdown preview](after-markdown-preview.png)
138 KB
Loading
155 KB
Loading
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { render, screen, waitFor, within } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import type { APIClient } from "../api/client";
5+
import type { WorkspaceExplorerView } from "../api/types";
6+
import { V2RecoveryProvider } from "../v2/recovery-storage";
7+
import { FileEditDraftProvider } from "./file-edit-drafts";
8+
import { WorkspaceExplorer } from "./workspace-explorer";
9+
10+
vi.mock("@monaco-editor/react", () => ({
11+
default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) =>
12+
<textarea aria-label="Monaco proposal editor" onChange={(event) => onChange(event.target.value)} value={value} />,
13+
}));
14+
vi.mock("../lib/monaco-local", () => ({ configureLocalMonaco: vi.fn() }));
15+
16+
describe("WorkspaceExplorer Markdown preview", () => {
17+
it("switches the returned content between source and safe formatted Markdown", async () => {
18+
const snapshot = markdownSnapshot([
19+
"# Project notes", "", "**Ready**", "", "- Review changes", "",
20+
"[web](https://example.com/docs) [relative](./notes.md) [local](file:///secret) [unsafe](javascript:alert(1))",
21+
"", "![tracking](https://example.com/pixel.png)", "<script>alert(1)</script>",
22+
"", "SESSION_SECRET=[REDACTED:secret]",
23+
].join("\n"));
24+
const client = previewClient(snapshot);
25+
const user = userEvent.setup();
26+
const view = renderExplorer(client);
27+
28+
expect(await screen.findByText("# Project notes")).toBeInTheDocument();
29+
expect(screen.getByRole("button", { name: "Source", pressed: true })).toBeInTheDocument();
30+
await user.click(screen.getByRole("button", { name: "Preview" }));
31+
const preview = screen.getByRole("region", { name: "Markdown preview" });
32+
expect(within(preview).getByRole("heading", { name: "Project notes" })).toBeInTheDocument();
33+
expect(within(preview).getByText("Ready").tagName).toBe("STRONG");
34+
expect(within(preview).getByRole("listitem")).toHaveTextContent("Review changes");
35+
expect(within(preview).getByRole("link", { name: "web" })).toHaveAttribute("target", "_blank");
36+
expect(within(preview).getByRole("link", { name: "web" })).toHaveAttribute("rel", "noreferrer noopener");
37+
for (const label of ["relative", "local", "unsafe"]) {
38+
expect(within(preview).getByText(label).tagName).toBe("SPAN");
39+
}
40+
expect(preview.querySelector("script, img")).not.toBeInTheDocument();
41+
expect(preview).toHaveTextContent("SESSION_SECRET=[REDACTED:secret]");
42+
expect(screen.getByText("1 redacted")).toBeInTheDocument();
43+
expect(view.container.querySelector(".explorer-file-lines")).not.toBeInTheDocument();
44+
45+
await user.click(screen.getByRole("button", { name: "Source" }));
46+
expect(screen.getByText("# Project notes")).toBeInTheDocument();
47+
expect(screen.queryByRole("region", { name: "Markdown preview" })).not.toBeInTheDocument();
48+
expect(client.workspaceExplore).toHaveBeenCalledTimes(1);
49+
});
50+
51+
it.each(["README.MD", "notes.markdown", "guide.mdown", "notes.txt"])("offers preview only for Markdown paths: %s", async (path) => {
52+
const client = previewClient({ ...markdownSnapshot(), path });
53+
renderExplorer(client, { initialPath: path });
54+
await screen.findByText("# Project notes");
55+
expect(Boolean(screen.queryByRole("button", { name: "Preview" }))).toBe(path !== "notes.txt");
56+
});
57+
58+
it("returns to source for search results and later citation lines", async () => {
59+
const client = previewClient(markdownSnapshot());
60+
const user = userEvent.setup();
61+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
62+
const explorer = (initialLine: number) => <QueryClientProvider client={queryClient}>
63+
<WorkspaceExplorer client={client} workspaceID="workspace-1" initialPath="README.md" initialLine={initialLine} />
64+
</QueryClientProvider>;
65+
const view = render(explorer(1));
66+
await user.click(await screen.findByRole("button", { name: "Preview" }));
67+
expect(screen.queryByText("Positioned at line 1")).not.toBeInTheDocument();
68+
await user.type(screen.getByLabelText("Search Workspace evidence"), "matched");
69+
await user.click(screen.getByRole("button", { name: "Search Workspace" }));
70+
await user.click(await screen.findByRole("button", { name: /README.md.*line 2/ }));
71+
expect(await screen.findByText("Positioned at line 2")).toBeInTheDocument();
72+
expect(screen.getByRole("button", { name: "Source", pressed: true })).toBeInTheDocument();
73+
expect(view.container.querySelector('[data-line="2"]')).toHaveClass("is-highlighted");
74+
75+
await user.click(screen.getByRole("button", { name: "Preview" }));
76+
view.rerender(explorer(3));
77+
expect(await screen.findByText("Positioned at line 3")).toBeInTheDocument();
78+
expect(screen.getByRole("button", { name: "Source", pressed: true })).toBeInTheDocument();
79+
});
80+
81+
it("references the same snapshot and retains truncation and provenance in preview", async () => {
82+
const snapshot = { ...markdownSnapshot(), truncated: true, total_bytes: 1024 };
83+
const onSelectReference = vi.fn();
84+
const client = previewClient(snapshot);
85+
const user = userEvent.setup();
86+
renderExplorer(client, { onSelectReference });
87+
await user.click(await screen.findByRole("button", { name: "Reference this file" }));
88+
await user.click(screen.getByRole("button", { name: "Preview" }));
89+
await user.click(screen.getByRole("button", { name: "Reference this file" }));
90+
expect(onSelectReference.mock.calls).toEqual([[snapshot], [snapshot]]);
91+
expect(screen.getByText("truncated")).toBeInTheDocument();
92+
expect(screen.getByText("workspace_file / File source record")).toBeInTheDocument();
93+
expect(screen.getByText("b".repeat(12))).toBeInTheDocument();
94+
expect(client.workspaceExplore).toHaveBeenCalledTimes(1);
95+
});
96+
97+
it("keeps an editing draft across returning to preview and switching views", async () => {
98+
const snapshot = { ...markdownSnapshot(), redaction_count: 0 };
99+
const client = Object.assign(previewClient(snapshot), {
100+
hasFileEditProposals: true,
101+
issueFileEditProposalSource: vi.fn().mockResolvedValue({
102+
protocol_version: "file_edit_proposal.v1", run_id: "run-1", workspace_id: "workspace-1",
103+
path: snapshot.path, content: snapshot.content, content_sha256: snapshot.provenance.content_sha256,
104+
source_handle: "B".repeat(43), expires_at: "2099-07-18T00:05:00Z", editable: true, file_write: false,
105+
}),
106+
createFileEditProposal: vi.fn(),
107+
});
108+
const user = userEvent.setup();
109+
renderExplorer(client, { runID: "run-1" });
110+
await user.click(await screen.findByRole("button", { name: "Preview" }));
111+
await user.click(screen.getByRole("button", { name: "Edit proposal" }));
112+
const editor = await screen.findByLabelText("Monaco proposal editor");
113+
expect(screen.queryByRole("group", { name: "File view" })).not.toBeInTheDocument();
114+
await user.clear(editor);
115+
await user.type(editor, "# Retained draft");
116+
await user.click(screen.getByRole("button", { name: "Close file proposal editor" }));
117+
await user.click(screen.getByRole("button", { name: "Keep draft" }));
118+
expect(screen.getByRole("region", { name: "Markdown preview" })).toBeInTheDocument();
119+
await user.click(screen.getByRole("button", { name: "Source" }));
120+
await user.click(screen.getByRole("button", { name: "Preview" }));
121+
await user.click(screen.getByRole("button", { name: "Edit proposal" }));
122+
await waitFor(() => expect(screen.getByLabelText("Monaco proposal editor")).toHaveValue("# Retained draft"));
123+
expect(client.createFileEditProposal).not.toHaveBeenCalled();
124+
});
125+
});
126+
127+
function markdownSnapshot(content = "# Project notes\nmatched text\nthird line\n"): WorkspaceExplorerView {
128+
return {
129+
protocol_version: "workspace_explorer.v1", workspace_id: "workspace-1", path: "README.md",
130+
kind: "file", entries: [], content, total_bytes: content.length, returned_bytes: content.length,
131+
truncated: false, redaction_count: 1, root_path_exposed: false,
132+
provenance: { version: "context_provenance.v1", source_kind: "workspace_file", source_ref: "README.md",
133+
content_sha256: "b".repeat(64), instruction_authorized: false },
134+
};
135+
}
136+
137+
function previewClient(snapshot: WorkspaceExplorerView) {
138+
return Object.assign({} as APIClient, {
139+
baseURL: "http://localhost:8765/api/v1",
140+
workspaceExplore: vi.fn().mockResolvedValue(snapshot),
141+
workspaceSearch: vi.fn().mockResolvedValue({ results: [{ path: "README.md", line: 2,
142+
match_kind: "content", snippet: "matched text", content_truncated: false,
143+
provenance: snapshot.provenance }], truncated: false }),
144+
});
145+
}
146+
147+
function renderExplorer(client: APIClient, props: Partial<Parameters<typeof WorkspaceExplorer>[0]> = {}) {
148+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
149+
return render(<QueryClientProvider client={queryClient}><V2RecoveryProvider client={client} scopeID="preview-tests"><FileEditDraftProvider>
150+
<WorkspaceExplorer client={client} workspaceID="workspace-1" initialPath="README.md" {...props} />
151+
</FileEditDraftProvider></V2RecoveryProvider></QueryClientProvider>);
152+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
.explorer-file > .explorer-file-summary {
2+
flex-wrap: wrap;
3+
padding: 6px 0;
4+
}
5+
6+
.explorer-view-switch {
7+
display: flex;
8+
gap: 4px;
9+
margin-right: auto;
10+
}
11+
12+
.explorer-view-switch .compact-command[aria-pressed="true"] {
13+
background: var(--prayu-control);
14+
color: var(--prayu-text);
15+
font-weight: 600;
16+
}
17+
18+
.explorer-markdown-preview {
19+
max-height: calc(100vh - 280px);
20+
min-height: 280px;
21+
padding: 16px;
22+
overflow: auto;
23+
border: 1px solid var(--border);
24+
border-radius: 5px;
25+
background: var(--prayu-surface-solid);
26+
color: var(--prayu-text);
27+
font-size: 13px;
28+
line-height: 1.6;
29+
}
30+
31+
.explorer-markdown-preview .safe-markdown pre {
32+
min-height: 0;
33+
max-height: none;
34+
}

‎web/src/components/workspace-explorer.tsx‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { useLocale } from "../lib/locale";
88
import { EmptyState, ErrorState, LoadingState, StatusBadge } from "./common";
99
import { FileProposalEditor } from "./file-proposal-editor";
1010
import { useFileEditLeave } from "./file-edit-drafts";
11+
import { SafeMarkdown } from "./safe-markdown";
12+
import "./workspace-explorer.css";
1113

1214
export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath = ".", initialLine, onSelectReference, onPathChange }: {
1315
client: APIClient;
@@ -20,7 +22,7 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath
2022
}) {
2123
const { t } = useLocale();
2224
const leave = useFileEditLeave();
23-
const [location, setLocation] = useState({ path: initialPath, line: initialLine });
25+
const [location, setLocation] = useState({ path: initialPath, line: initialLine, view: "source" as "source" | "preview" });
2426
const { path, line: highlightLine } = location;
2527
const highlightedLineRef = useRef<HTMLDivElement>(null);
2628
const [searchInput, setSearchInput] = useState("");
@@ -35,7 +37,7 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath
3537
reported.path === initialPath && initialLine === undefined;
3638
reportedNavigation.current = undefined;
3739
if (!isNavigationEcho) {
38-
setLocation({ path: initialPath, line: initialLine });
40+
setLocation({ path: initialPath, line: initialLine, view: "source" });
3941
}
4042
setSearchInput("");
4143
setSearchQuery("");
@@ -74,7 +76,7 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath
7476
const parent = useMemo(() => parentPath(path), [path]);
7577
const navigate = (nextPath: string, nextLine?: number) => leave(() => {
7678
proposalSource.reset();
77-
setLocation({ path: nextPath, line: nextLine });
79+
setLocation({ path: nextPath, line: nextLine, view: "source" });
7880
reportedNavigation.current = onPathChange && (nextPath !== initialPath || initialLine !== undefined)
7981
? { workspaceID, runID, path: nextPath } : undefined;
8082
onPathChange?.(nextPath);
@@ -186,22 +188,32 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath
186188
<FileProposalEditor client={client} onClose={() => proposalSource.reset()}
187189
runID={runID} source={proposalSource.data} /> :
188190
snapshot.kind === "file" && (() => {
191+
const isMarkdown = /\.(md|markdown|mdown)$/iu.test(snapshot.path);
192+
const showPreview = isMarkdown && location.view === "preview";
189193
const lines = snapshot.content.split("\n");
190194
const lineOutOfRange = highlightLine !== undefined && (highlightLine < 1 || highlightLine > lines.length);
191195
const lineHighlighted = highlightLine !== undefined && highlightLine >= 1 && highlightLine <= lines.length;
192196
return <div className="explorer-file">
193-
<div>
197+
<div className="explorer-file-summary">
198+
{isMarkdown && <div className="explorer-view-switch" role="group" aria-label={t("文件视图", "File view")}>
199+
<button aria-pressed={!showPreview} className="compact-command" type="button"
200+
onClick={() => setLocation((current) => ({ ...current, view: "source" }))}>{t("源码", "Source")}</button>
201+
<button aria-pressed={showPreview} className="compact-command" type="button"
202+
onClick={() => setLocation((current) => ({ ...current, view: "preview" }))}>{t("预览", "Preview")}</button>
203+
</div>}
194204
<span>{t(`已显示 ${formatBytes(snapshot.returned_bytes)}`, `${formatBytes(snapshot.returned_bytes)} shown`)}</span>
195205
<span>{t(`共 ${formatBytes(snapshot.total_bytes)}`, `${formatBytes(snapshot.total_bytes)} total`)}</span>
196206
</div>
197-
{lineOutOfRange && <div className="explorer-line-warning" role="alert">
207+
{!showPreview && lineOutOfRange && <div className="explorer-line-warning" role="alert">
198208
{t(`定位到第 ${highlightLine} 行失败:超出当前显示范围(已显示 ${lines.length} 行${snapshot.truncated ? ",文件已截断" : ""})`,
199209
`Failed to position line ${highlightLine}: out of loaded range (${lines.length} lines shown${snapshot.truncated ? ", truncated" : ""})`)}
200210
</div>}
201-
{lineHighlighted && <div className="explorer-line-notice" role="status">
211+
{!showPreview && lineHighlighted && <div className="explorer-line-notice" role="status">
202212
{t(`已定位到第 ${highlightLine} 行`, `Positioned at line ${highlightLine}`)}
203213
</div>}
204-
<div className="explorer-file-lines">
214+
{showPreview ? <section className="explorer-markdown-preview" aria-label={t("Markdown 预览", "Markdown preview")}>
215+
<SafeMarkdown>{snapshot.content}</SafeMarkdown>
216+
</section> : <div className="explorer-file-lines">
205217
{lines.map((lineContent, index) => {
206218
const lineNum = index + 1;
207219
const isHighlight = lineNum === highlightLine;
@@ -211,7 +223,7 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath
211223
<span className="explorer-line-content">{lineContent}</span>
212224
</div>;
213225
})}
214-
</div>
226+
</div>}
215227
</div>;
216228
})()}
217229
</section>;

0 commit comments

Comments
 (0)