Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions web/src/components/workspace-explorer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,34 @@ describe("WorkspaceExplorer", () => {
expect(await screen.findByText("Evidence attached as non-authorizing context"))
.toBeInTheDocument();
});

it("positions and highlights a valid requested line", async () => {
const workspaceExplore = vi.fn().mockResolvedValue(fileSnapshot());
const client = { workspaceExplore } as unknown as APIClient;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<WorkspaceExplorer client={client} workspaceID="workspace-1" initialPath="README.md" initialLine={2} />
</QueryClientProvider>
);

expect(await screen.findByText(/Positioned at line 2|已定位到第 2 行/)).toBeInTheDocument();
const line2 = screen.getByText("Notes for automated assistants: skip setup.").closest(".explorer-file-line");
expect(line2).toHaveClass("is-highlighted");
});

it("warns when requested line exceeds the visible lines", async () => {
const workspaceExplore = vi.fn().mockResolvedValue(fileSnapshot());
const client = { workspaceExplore } as unknown as APIClient;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<WorkspaceExplorer client={client} workspaceID="workspace-1" initialPath="README.md" initialLine={99} />
</QueryClientProvider>
);

expect(await screen.findByRole("alert")).toHaveTextContent(/out of loaded range|超出当前显示范围/);
});
});

function directorySnapshot() {
Expand Down
50 changes: 42 additions & 8 deletions web/src/components/workspace-explorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,38 @@ import { useLocale } from "../lib/locale";
import { EmptyState, ErrorState, LoadingState, StatusBadge } from "./common";
import { FileProposalEditor } from "./file-proposal-editor";

export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath = ".", onSelectReference }: {
export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath = ".", initialLine, onSelectReference }: {
client: APIClient;
workspaceID: string;
runID?: string;
initialPath?: string;
initialLine?: number;
onSelectReference?: (file: WorkspaceExplorerView) => void;
}) {
const { t } = useLocale();
const [path, setPath] = useState(initialPath);
const [highlightLine, setHighlightLine] = useState<number | undefined>(initialLine);
const highlightedLineRef = useRef<HTMLDivElement>(null);
const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const operationKeys = useRef(new Map<string, string>());
useEffect(() => {
setPath(initialPath);
setHighlightLine(initialLine);
setSearchInput("");
setSearchQuery("");
operationKeys.current.clear();
}, [workspaceID, runID, initialPath]);
}, [workspaceID, runID, initialPath, initialLine]);
const query = useQuery({
queryKey: ["workspace", workspaceID, "explore", path],
queryFn: ({ signal }) => client.workspaceExplore(workspaceID, path, signal),
enabled: Boolean(workspaceID),
});
useEffect(() => {
if (highlightLine && highlightedLineRef.current) {
highlightedLineRef.current.scrollIntoView?.({ block: "center", behavior: "smooth" });
}
}, [highlightLine, path, query.data]);
const search = useQuery({
queryKey: ["workspace", workspaceID, "search", searchQuery],
queryFn: ({ signal }) => client.workspaceSearch(workspaceID, searchQuery, signal),
Expand Down Expand Up @@ -66,7 +75,8 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath

if (!workspaceID) return <EmptyState>{t("此 Run 未绑定工作区", "No Workspace is bound to this Run")}</EmptyState>;
if (query.isLoading) return <LoadingState label={t("正在加载工作区文件", "Loading Workspace files")} />;
if (query.isError || !query.data) return <div><ErrorState error={query.error} />
if (query.isError || !query.data) return <div className="explorer-error"><ErrorState error={query.error} />
<p className="explorer-error-hint" role="alert">{t("无法读取目标路径,文件可能不存在或已移动", "Cannot read target path, file may not exist or has been moved")}</p>
<button onClick={() => void query.refetch()} type="button">{t("重试文件读取", "Retry file read")}</button>
<button onClick={() => setPath(parent)} type="button">{t("返回上级目录", "Return to parent")}</button></div>;
const snapshot = query.data;
Expand Down Expand Up @@ -140,11 +150,35 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath
{proposalSource.data && proposalSource.data.path === snapshot.path ?
<FileProposalEditor client={client} onClose={() => proposalSource.reset()}
runID={runID} source={proposalSource.data} /> :
snapshot.kind === "file" && <div className="explorer-file">
<div><span>{t(`已显示 ${formatBytes(snapshot.returned_bytes)}`, `${formatBytes(snapshot.returned_bytes)} shown`)}</span>
<span>{t(`共 ${formatBytes(snapshot.total_bytes)}`, `${formatBytes(snapshot.total_bytes)} total`)}</span></div>
<pre>{snapshot.content}</pre>
</div>}
snapshot.kind === "file" && (() => {
const lines = snapshot.content.split("\n");
const lineOutOfRange = highlightLine !== undefined && (highlightLine < 1 || highlightLine > lines.length);
const lineHighlighted = highlightLine !== undefined && highlightLine >= 1 && highlightLine <= lines.length;
return <div className="explorer-file">
<div>
<span>{t(`已显示 ${formatBytes(snapshot.returned_bytes)}`, `${formatBytes(snapshot.returned_bytes)} shown`)}</span>
<span>{t(`共 ${formatBytes(snapshot.total_bytes)}`, `${formatBytes(snapshot.total_bytes)} total`)}</span>
</div>
{lineOutOfRange && <div className="explorer-line-warning" role="alert">
{t(`定位到第 ${highlightLine} 行失败:超出当前显示范围(已显示 ${lines.length} 行${snapshot.truncated ? ",文件已截断" : ""})`,
`Failed to position line ${highlightLine}: out of loaded range (${lines.length} lines shown${snapshot.truncated ? ", truncated" : ""})`)}
</div>}
{lineHighlighted && <div className="explorer-line-notice" role="status">
{t(`已定位到第 ${highlightLine} 行`, `Positioned at line ${highlightLine}`)}
</div>}
<div className="explorer-file-lines">
{lines.map((lineContent, index) => {
const lineNum = index + 1;
const isHighlight = lineNum === highlightLine;
return <div className={`explorer-file-line${isHighlight ? " is-highlighted" : ""}`}
key={lineNum} ref={isHighlight ? highlightedLineRef : undefined} data-line={lineNum}>
<span className="explorer-line-number">{lineNum}</span>
<span className="explorer-line-content">{lineContent}</span>
</div>;
})}
</div>
</div>;
})()}
</section>;
}

Expand Down
11 changes: 10 additions & 1 deletion web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -857,8 +857,17 @@ td code { font-size: 11px; }
.explorer-list button:disabled { cursor: not-allowed; color: #8b9499; }
.explorer-list button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.explorer-list button small { color: var(--muted); font-size: 10px; }
.explorer-file > div { min-height: 34px; display: flex; justify-content: flex-end; align-items: center; gap: 12px; color: var(--muted); font-size: 10px; }
.explorer-file > div:not(.explorer-file-lines) { min-height: 34px; display: flex; justify-content: flex-end; align-items: center; gap: 12px; color: var(--muted); font-size: 10px; }
.explorer-file pre { max-height: calc(100vh - 280px); min-height: 280px; margin: 0; padding: 14px; overflow: auto; border: 1px solid var(--border); border-radius: 5px; background: #f6f8f9; color: #26343d; font-size: 11px; line-height: 1.55; white-space: pre; }
.explorer-file-lines { display: block; max-height: calc(100vh - 280px); min-height: 280px; margin: 0; padding: 10px 0; overflow: auto; border: 1px solid var(--border); border-radius: 5px; background: #f6f8f9; color: #26343d; font-size: 11px; line-height: 1.55; font-family: var(--prayu-font-mono, monospace); }
.explorer-file-line { display: flex; padding: 0 8px; }
.explorer-file-line.is-highlighted { background: color-mix(in srgb, var(--accent, #1a73e8) 18%, transparent); font-weight: 600; }
.explorer-line-number { min-width: 44px; user-select: none; color: var(--muted); text-align: right; padding-right: 14px; flex-shrink: 0; }
.explorer-line-content { white-space: pre; flex: 1 1 auto; }
.explorer-line-warning { padding: 8px 12px; background: color-mix(in srgb, #ef6b5d 15%, transparent); color: #c53929; border: 1px solid color-mix(in srgb, #ef6b5d 30%, transparent); border-radius: 6px; font-size: 12px; margin-bottom: 8px; }
.explorer-line-notice { padding: 8px 12px; background: color-mix(in srgb, #1a73e8 12%, transparent); color: #1a73e8; border: 1px solid color-mix(in srgb, #1a73e8 25%, transparent); border-radius: 6px; font-size: 12px; margin-bottom: 8px; }
.explorer-error-hint { color: var(--muted); font-size: 13px; margin: 6px 0 12px; }
.v2-terminal-error-notice { padding: 8px 12px; background: color-mix(in srgb, #ef6b5d 15%, transparent); color: #c53929; border: 1px solid color-mix(in srgb, #ef6b5d 30%, transparent); border-radius: 6px; font-size: 12px; margin: 8px 16px; }
.file-proposal-editor { display: grid; grid-template-rows: auto minmax(360px, calc(100vh - 360px)) auto; min-width: 0; margin-top: 12px; border: 1px solid var(--border-strong); border-radius: 6px; overflow: hidden; background: var(--surface); }
.file-proposal-editor > header, .file-proposal-editor > footer { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 7px 10px; background: #f6f8f9; }
.file-proposal-editor > header { border-bottom: 1px solid var(--border); }
Expand Down
Loading
Loading