Skip to content

Commit 083f722

Browse files
Ubuntuclaude
andcommitted
fix(desktop/read): PDF TOC resize (real root cause) + robust jump + reveal-file button
#1 The PDF outline panel's width was fixed no matter what — the true cause was a CSS class collision, not the drag logic: the panel carried `pdfjs-toc scroll`, and the `.scroll` utility (`flex: 1`, defined LATER in the sheet than `.pdfjs-toc`'s `flex: 0 0 auto`) won on equal specificity, so `flex-basis: 0` + `flex-grow: 1` overrode the inline `width: tocW` entirely — the panel was sized by flex-grow, and dragging updated tocW to no effect. Dropped the `scroll` class (the panel already sets its own `overflow: auto`). This is why prior ResizeHandle fixes didn't help. #1 ToC/inline-link jumps could land short: pages ABOVE the target may still be reserving their true height (lazy render, late pageDims), shifting the target after the scroll starts. scrollToPage now settles — recomputes the absolute target and re-scrolls whenever it drifts, until the layout above stabilises. #2 Attachment card gains a "Show in folder" action (+ inline folder icon): a new `reveal_path` command reveals the file in the OS file manager — `explorer /select` (Windows), `open -R` (macOS), containing folder via `xdg-open` (Linux). Path is a single process arg, never a shell string. Zotero-style left panel (thumbnails/outline tabs) + annotation tools are the next slice. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 890756d commit 083f722

6 files changed

Lines changed: 138 additions & 19 deletions

File tree

desktop/src-tauri/src/lib.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,58 @@ fn open_url(url: &str) -> Result<(), String> {
340340
.map_err(|e| e.to_string())
341341
}
342342

343+
/// Reveal a file in the OS file manager (selecting it where the platform supports
344+
/// it), so the user can find a linked attachment on disk. The path is passed as a
345+
/// single process argument — never through a shell — so it can't inject a command.
346+
#[tauri::command]
347+
fn reveal_path(path: String) -> Result<(), String> {
348+
if path.trim().is_empty() {
349+
return Err("empty path".into());
350+
}
351+
reveal(&path)
352+
}
353+
354+
#[cfg(target_os = "windows")]
355+
fn reveal(path: &str) -> Result<(), String> {
356+
use std::os::windows::process::CommandExt;
357+
use std::process::Command;
358+
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
359+
// `explorer /select,"<path>"` selects the file in its folder. explorer returns
360+
// a non-zero exit code even on success, so we only require that it spawned.
361+
// raw_arg keeps our exact quoting (a Windows path can't contain a `"`), which
362+
// Rust's default arg-quoting would otherwise mangle for explorer.
363+
Command::new("explorer")
364+
.raw_arg(format!("/select,\"{path}\""))
365+
.creation_flags(CREATE_NO_WINDOW)
366+
.spawn()
367+
.map(|_| ())
368+
.map_err(|e| e.to_string())
369+
}
370+
371+
#[cfg(target_os = "macos")]
372+
fn reveal(path: &str) -> Result<(), String> {
373+
// `open -R` reveals and selects the file in Finder.
374+
std::process::Command::new("open")
375+
.args(["-R", path])
376+
.spawn()
377+
.map(|_| ())
378+
.map_err(|e| e.to_string())
379+
}
380+
381+
#[cfg(all(unix, not(target_os = "macos")))]
382+
fn reveal(path: &str) -> Result<(), String> {
383+
// No portable "select the file" on Linux desktops — open the containing folder.
384+
let target = std::path::Path::new(path)
385+
.parent()
386+
.map(std::path::Path::to_path_buf)
387+
.unwrap_or_else(|| std::path::PathBuf::from(path));
388+
std::process::Command::new("xdg-open")
389+
.arg(target)
390+
.spawn()
391+
.map(|_| ())
392+
.map_err(|e| e.to_string())
393+
}
394+
343395
// ---- in-app browser window --------------------------------------------------
344396
// The J1 in-app browser tab is an <iframe>, which many sites forbid via
345397
// `X-Frame-Options` / `frame-ancestors` (arxiv.org, Google Scholar, most
@@ -424,6 +476,7 @@ pub fn run() {
424476
hub_request_bytes,
425477
system_proxy,
426478
open_external,
479+
reveal_path,
427480
open_browser_window,
428481
storage::storage_pick_folder,
429482
storage::storage_reindex,

desktop/src/i18n/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,7 @@ const en: Dict = {
686686
'read.attType': 'Type',
687687
'read.attLocation': 'Location',
688688
'read.attOpen': 'Open in reader',
689+
'read.attReveal': 'Show in folder',
689690
'read.attSession': 'Linked this session',
690691
'read.attMissing': 'Not found in the linked folder',
691692
'read.attNotLinked': 'No storage folder linked — link one in the Read tab',
@@ -1525,6 +1526,7 @@ const zh: Dict = {
15251526
'read.attType': '类型',
15261527
'read.attLocation': '位置',
15271528
'read.attOpen': '在阅读器中打开',
1529+
'read.attReveal': '在文件夹中显示',
15281530
'read.attSession': '本次会话已链接',
15291531
'read.attMissing': '在已链接文件夹中未找到',
15301532
'read.attNotLinked': '未链接存储文件夹 — 请在“阅读”标签中链接',

desktop/src/platform.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ export function openExternal(url: string): void {
2727
}
2828
}
2929

30+
/// Reveal a local file in the OS file manager (Finder / Explorer / files app),
31+
/// selecting it where the platform supports it. Desktop-only; a no-op in the
32+
/// browser build (no filesystem access).
33+
export function revealPath(path: string): void {
34+
if (path === '' || !isTauri()) return;
35+
void import('@tauri-apps/api/core')
36+
.then(({ invoke }) => invoke('reveal_path', { path }))
37+
.catch(() => {
38+
/* best effort */
39+
});
40+
}
41+
3042
/// Open a URL in a real in-app browser *window* (a Tauri webview, not an iframe),
3143
/// so sites that forbid framing (`X-Frame-Options` — arxiv, Google Scholar, most
3244
/// publishers) still load. The browser build falls back to a new tab.

desktop/src/styles/app.css

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5085,9 +5085,23 @@ th {
50855085
font-family: var(--font-mono, monospace);
50865086
font-size: var(--font-size-caption);
50875087
}
5088+
.att-reveal {
5089+
flex: 0 0 auto;
5090+
align-self: center;
5091+
color: var(--text-muted);
5092+
padding: 2px;
5093+
}
5094+
.att-reveal:hover {
5095+
color: var(--accent);
5096+
}
5097+
.att-actions {
5098+
display: flex;
5099+
flex-wrap: wrap;
5100+
gap: var(--spacing-s8);
5101+
margin-top: var(--spacing-s4);
5102+
}
50885103
.att-open {
50895104
align-self: flex-start;
5090-
margin-top: var(--spacing-s4);
50915105
}
50925106
.epub-view {
50935107
display: flex;

desktop/src/surfaces/ReadSurface.tsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
type ScrapePatch,
2323
type ScrapeSeed,
2424
} from '../discovery';
25-
import { isTauri } from '../platform';
25+
import { isTauri, revealPath } from '../platform';
2626
import { BrowserView } from './BrowserView';
2727
import { AgentCompanion } from '../ui/AgentCompanion';
2828
import { Markdown } from '../ui/Markdown';
@@ -379,10 +379,12 @@ function AttachmentInfo({
379379
const kind = viewKindFor(att.file);
380380
const ext = att.file.split('.').pop()?.toLowerCase() ?? '';
381381
const rel = rels.get(k);
382+
// The absolute on-disk path, when known (Tauri storage link) — lets us reveal
383+
// the file in the OS file manager. The browser build has only live File handles,
384+
// so there is no path to reveal.
385+
const absPath = present && rel !== undefined && path !== null ? `${path}/${rel}` : null;
382386
const location = present
383-
? rel !== undefined && path !== null
384-
? `${path}/${rel}`
385-
: t('read.attSession')
387+
? absPath ?? t('read.attSession')
386388
: storageLinked
387389
? t('read.attMissing')
388390
: t('read.attNotLinked');
@@ -429,13 +431,26 @@ function AttachmentInfo({
429431
<div className="att-field">
430432
<span className="att-k">{t('read.attLocation')}</span>
431433
<span className={present ? 'att-v mono' : 'att-v mono muted'}>{location}</span>
434+
{absPath !== null && (
435+
<button className="link-btn att-reveal" title={t('read.attReveal')} onClick={() => revealPath(absPath)}>
436+
<Icon name="folder" size={14} />
437+
</button>
438+
)}
439+
</div>
440+
<div className="att-actions">
441+
{absPath !== null && (
442+
<button className="small att-locate" onClick={() => revealPath(absPath)}>
443+
<Icon name="folder" size={14} />
444+
{t('read.attReveal')}
445+
</button>
446+
)}
447+
{present && embedded !== true && onOpen !== undefined && (
448+
<button className="primary small att-open" onClick={onOpen}>
449+
<Icon name="window" />
450+
{t('read.attOpen')}
451+
</button>
452+
)}
432453
</div>
433-
{present && embedded !== true && onOpen !== undefined && (
434-
<button className="primary small att-open" onClick={onOpen}>
435-
<Icon name="window" />
436-
{t('read.attOpen')}
437-
</button>
438-
)}
439454
</div>
440455
</div>
441456
</div>

desktop/src/ui/PdfCanvas.tsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -441,15 +441,38 @@ export function PdfCanvas({
441441
}
442442

443443
// Scroll so page `n`'s top (plus an optional in-page `yOffset`) sits just under
444-
// the toolbar. Computed against the container's own scroll frame (not
445-
// scrollIntoView) so an in-page offset can be added, and accurate because every
446-
// page already reserves its true height via pageDims.
444+
// the toolbar. The target is an ABSOLUTE content offset (invariant to the current
445+
// scroll position), computed from the page's rect within the container's scroll
446+
// frame — so an in-page offset can be added.
447+
//
448+
// A jump can otherwise land short: pages ABOVE the target may still be reserving
449+
// their true height (lazy render, or pageDims not yet measured), which shifts the
450+
// target down after the scroll begins. So we settle: recompute the target a few
451+
// times and re-scroll whenever it drifts, until the layout above has stabilised.
447452
function scrollToPage(n: number, yOffset = 0): void {
448453
const container = scrollRef.current;
449-
const el = container?.querySelector(`[data-page="${n}"]`);
450-
if (!(el instanceof HTMLElement) || container === null) return;
451-
const top = container.scrollTop + (el.getBoundingClientRect().top - container.getBoundingClientRect().top) + yOffset;
452-
container.scrollTo({ top: Math.max(0, top - 8), behavior: 'smooth' });
454+
if (container === null) return;
455+
const targetTop = (): number | null => {
456+
const el = container.querySelector(`[data-page="${n}"]`);
457+
if (!(el instanceof HTMLElement)) return null;
458+
const off = el.getBoundingClientRect().top - container.getBoundingClientRect().top;
459+
return Math.max(0, container.scrollTop + off + yOffset - 8);
460+
};
461+
const first = targetTop();
462+
if (first === null) return;
463+
container.scrollTo({ top: first, behavior: 'smooth' });
464+
let last = first;
465+
let tries = 0;
466+
const settle = (): void => {
467+
tries += 1;
468+
const t = targetTop();
469+
if (t !== null && Math.abs(t - last) > 3) {
470+
last = t;
471+
container.scrollTo({ top: t, behavior: 'smooth' });
472+
}
473+
if (tries < 8) window.setTimeout(settle, 120);
474+
};
475+
window.setTimeout(settle, 160);
453476
}
454477

455478
async function runSearch(): Promise<void> {
@@ -588,7 +611,7 @@ export function PdfCanvas({
588611
<div className="pdfjs-body">
589612
{showToc && outline.length > 0 && (
590613
<>
591-
<div className="pdfjs-toc scroll" style={{ width: tocW }}>
614+
<div className="pdfjs-toc" style={{ width: tocW }}>
592615
<OutlineList nodes={outline} onGo={(d) => void goToDest(d)} depth={0} />
593616
</div>
594617
<ResizeHandle

0 commit comments

Comments
 (0)