Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions .macroscope/check-run-agents/ui-consistency.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put
- Repeated native or virtualized overflow fades should use the shared virtualized-scroll-fade contract rather than component-named mask selectors.
- Preserve runtime top and bottom overflow state. Do not replace dynamic fades with an always-on static mask.
- Preserve fade geometry and keep the native scrollbar lane opaque so the track and thumb stay visible and usable. A visually similar mask that fades the scrollbar is a regression.
- For path-driven tree actions with flattened directory rows, do not assume that every registered item path is currently visible. Resolve the target through the tree's visible projection before selecting, focusing, or scrolling, and verify intermediate directory actions against a real flattened chain.
- Verify actual scroll behavior when changing virtualizers, masks, overflow ownership, or scrollbar selectors. Source-level class comparison is not enough.

## Visual and layout preservation
Expand Down
78 changes: 78 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ interface FileBrowserPanelProps {
selectedPath: string | null;
/** Bumped when the same path should be revealed again (e.g. re-opened from search). */
selectedPathRevealId: number;
/** Breadcrumb target to reveal in the tree. An empty path reveals the project root. */
breadcrumbRevealPath: string | null;
breadcrumbRevealId: number;
onOpenFile: (relativePath: string) => void;
}

Expand Down Expand Up @@ -104,6 +107,8 @@ export default function FileBrowserPanel({
projectName,
selectedPath,
selectedPathRevealId,
breadcrumbRevealPath,
breadcrumbRevealId,
onOpenFile,
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
Expand All @@ -120,6 +125,7 @@ export default function FileBrowserPanel({
const syncingSelectionRef = useRef(false);
const treeSelectionPathRef = useRef<string | null>(null);
const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null);
const handledBreadcrumbRevealRef = useRef<{ path: string; revealId: number } | null>(null);

// The tree renders rows in shadow DOM and its anchor rect is unreliable, so
// capture the right-click position ourselves; contextmenu is a composed
Expand Down Expand Up @@ -319,6 +325,78 @@ export default function FileBrowserPanel({
});
}, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]);

useEffect(() => {
if (breadcrumbRevealPath === null) return;
const revealRequest = { path: breadcrumbRevealPath, revealId: breadcrumbRevealId };
const handledReveal = handledBreadcrumbRevealRef.current;
if (
handledReveal?.path === revealRequest.path &&
handledReveal.revealId === revealRequest.revealId
) {
return;
}

const entryKind = breadcrumbRevealPath ? entryKinds.get(breadcrumbRevealPath) : "directory";
if (!entryKind) return;
const itemPath =
entryKind === "directory" && breadcrumbRevealPath
? `${breadcrumbRevealPath}/`
: breadcrumbRevealPath;
const item = itemPath ? (model.getItem(itemPath) ?? model.getItem(breadcrumbRevealPath)) : null;
if ((breadcrumbRevealPath && !item) || (!breadcrumbRevealPath && !treePaths[0])) return;

model.closeSearch();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

const segments = breadcrumbRevealPath.split("/").filter(Boolean);
let ancestorPath = "";
for (const segment of entryKind === "directory" ? segments : segments.slice(0, -1)) {
ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment;
const ancestor = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath);
if (ancestor && "expand" in ancestor) ancestor.expand();
}

let visibleItemPath: string | null = null;
if (itemPath) {
// Hidden segments in a flattened chain resolve to their visible ancestor,
// so probe descendants until the projected row belongs to the clicked directory.
const candidates = [
itemPath,
...(entryKind === "directory"
? treePaths.filter(
(path) => path !== itemPath && path.startsWith(itemPath) && path.endsWith("/"),
)
: []),
];
for (const candidate of candidates) {
const projectedPath = model.focusNearestPath(candidate);
const matchesTarget =
projectedPath === itemPath ||
(entryKind === "directory" && projectedPath?.startsWith(itemPath));
if (projectedPath && matchesTarget) {
visibleItemPath = projectedPath;
break;
}
}
if (!visibleItemPath) return;
}

handledBreadcrumbRevealRef.current = revealRequest;
syncingSelectionRef.current = true;
for (const path of model.getSelectedPaths()) {
model.getItem(path)?.deselect();
}

if (visibleItemPath) {
model.getItem(visibleItemPath)?.select();
model.scrollToPath(visibleItemPath, { focus: true, offset: "center" });
} else if (treePaths[0]) {
model.scrollToPath(treePaths[0], { focus: true, offset: "top" });
}
queueMicrotask(() => {
syncingSelectionRef.current = false;
});
}, [breadcrumbRevealId, breadcrumbRevealPath, entryKinds, model, treePaths]);

// Tag tree drags with the composer mention payload. The row is read from
// the composed event path (the tree's shadow root is open), so this does
// not depend on running after the tree's own dragstart handler; the drag
Expand Down
24 changes: 21 additions & 3 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,9 @@ export default function FilePreviewPanel({
const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath);
const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage);
const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
const [breadcrumbReveal, setBreadcrumbReveal] = useState<{ path: string; id: number } | null>(
null,
);
Comment thread
t3-code[bot] marked this conversation as resolved.
// Reading markdown rendered is a preference, not a property of one file. Keeping
// it on the panel meant a thread switch dropped it and forced source back.
const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(
Expand Down Expand Up @@ -832,6 +835,16 @@ export default function FilePreviewPanel({
});
};

const revealBreadcrumb = (path: string) => {
setExplorerOpen(true);
try {
setLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, true, Schema.Boolean);
} catch (error) {
console.error(error);
}
setBreadcrumbReveal((current) => ({ path, id: (current?.id ?? 0) + 1 }));
};

const handleOpenInBrowser = useCallback(() => {
if (!absolutePath || !environmentHttpBaseUrl) return;
void (async () => {
Expand Down Expand Up @@ -880,17 +893,20 @@ export default function FilePreviewPanel({
{index > 0 ? (
<ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
) : null}
<span
<button
type="button"
className={cn(
"max-w-40 truncate",
"max-w-40 cursor-pointer truncate rounded-sm px-0.5 hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
crumb.kind === "file"
? "font-medium text-foreground"
: "text-muted-foreground",
)}
title={crumb.path || projectName}
aria-label={`Reveal ${crumb.label} in file explorer`}
onClick={() => revealBreadcrumb(crumb.path)}
>
{crumb.label}
</span>
</button>
</div>
))}
</div>
Expand Down Expand Up @@ -1071,6 +1087,8 @@ export default function FilePreviewPanel({
projectName={projectName}
selectedPath={relativePath}
selectedPathRevealId={revealRequestId}
breadcrumbRevealPath={breadcrumbReveal?.path ?? null}
breadcrumbRevealId={breadcrumbReveal?.id ?? 0}
onOpenFile={onOpenFile}
/>
</aside>
Expand Down
Loading