diff --git a/README.md b/README.md index a913bc3..eac236e 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Or install via [Homebrew or one-liner](#install). - **Jump-to-file:** fuzzy finder across every workspace, opens with Cmd+P (binding configurable) - **Document outline:** auto-built TOC that follows the active heading as you scroll - **Tabs:** many docs open at once; scroll position remembered per tab +- **Split view:** read two docs side-by-side or stacked; each pane keeps its own tabs, scroll, and external-change banner. Toggle from the header, drag the splitter to resize, or use Cmd+\ (horizontal), Cmd+Shift+\ (vertical), Cmd+1 / Cmd+2 to focus a pane. "Open in other pane" lives in the file context menu. - **Search:** filename, path, frontmatter title, or tag - **Sticky favorites:** pin individual files to the top of any workspace - **Clutter rules:** glob patterns silently exclude files and folders from the explorer @@ -69,14 +70,16 @@ See [ROADMAP.md](./ROADMAP.md) for the full picture. Short version: - **Next:** find-in-page, full-text search, focus mode, recognition for markdown task formats (Backlog.md, taskmd, generic frontmatter). - **Later:** PDF export, kanban view over recognized task files, drag-a-folder-to-add-root, file management. -- **Considering:** plugin API, annotations, side-by-side view, local "smart" features (related-docs, TL;DR). +- **Considering:** plugin API, annotations, drag tabs between panes / N-pane nesting, local "smart" features (related-docs, TL;DR). ## Screenshots | | | |---|---| -| ![Light theme](docs/screenshots/light-theme.png) | ![Settings](docs/screenshots/settings.png) | -| ![Search](docs/screenshots/search.png) | ![Context menu](docs/screenshots/context-menu.png) | +| ![Light theme](docs/screenshots/light-theme.png) | ![Split view, side-by-side](docs/screenshots/split.png) | +| ![Split view, stacked (dark)](docs/screenshots/horizontal-split.png) | ![Split view, dark](docs/screenshots/split-dark.png) | +| ![Settings](docs/screenshots/settings.png) | ![Search](docs/screenshots/search.png) | +| ![Context menu](docs/screenshots/context-menu.png) | | ## Install diff --git a/ROADMAP.md b/ROADMAP.md index e872304..cc13ce6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,13 +56,16 @@ Direction unclear, community input wanted before committing. - **Plugin API** - expose the parser registry as a community-extensible plugin system once usage justifies the API stability commitment. Obsidian-style if it earns it. - **Annotations** - highlights and notes that travel with the document. Would shift DocsReader from a reader to a research tool, meaningful identity change. -- **Side-by-side view** - split panes for comparing two docs or following inline links. +- **Drag tabs between panes / N-pane nesting** - extend the v0.5 split view to support dragging a tab from one pane to the other, plus arbitrary nesting beyond two panes. Deferred until the 2-pane MVP has real usage feedback. - **Local "smart" features** - related-docs ("you may also want…") via TF-IDF, extractive TL;DR via TextRank. AI-feeling, no AI service. - **Drag-to-update task status** - requires DocsReader to write user files (currently read-only). Trust shift. ## Recently shipped -### Unreleased (in main since v0.3.0) +### Unreleased (in main since v0.4.0) +- **Split view** - side-by-side or stacked panes for reading two docs at once. Toggle from the header (single / horizontal / vertical), drag the splitter to resize, "Open in other pane" context-menu entry. Each pane keeps its own tabs, scroll, and external-change banner; the outline tracks whichever pane is focused. Keyboard shortcuts: `Cmd+\` toggles horizontal, `Cmd+Shift+\` toggles vertical, `Cmd+1` / `Cmd+2` focus pane 0 / pane 1. Pane 1's tabs persist across single/split toggles so re-splitting brings them back exactly as they were. + +### v0.4.0 - **`.docs.yaml` v0.1 manifest support** - projects shipping a manifest get curated navigation (hand-curated `items` and auto-listed `folder` sections with sort, title-from, badges, nesting), project metadata in the workspace switcher, automatic homepage open on first add, cross-project links between open workspaces, ignore patterns, a visibility toggle for previewing public-only views, and a sidebar pane that surfaces manifest issues. - **Git integration (T1+T2)** - per-file status badges in the file tree (M / A / D / R / ? / U) for workspaces inside a git repo; "Show git diff" context menu opens a diff vs HEAD with unified or side-by-side view and word-level highlighting. Git binary auto-discovered across PATH plus common Homebrew locations. - **External-change banner** - when a file open in a tab changes on disk, a banner shows what changed with reload / show-diff / dismiss / always-auto-reload actions; same diff dialog as the git diff feature. diff --git a/docs/screenshots/horizontal-split.png b/docs/screenshots/horizontal-split.png new file mode 100644 index 0000000..1580a84 Binary files /dev/null and b/docs/screenshots/horizontal-split.png differ diff --git a/docs/screenshots/split-dark.png b/docs/screenshots/split-dark.png new file mode 100644 index 0000000..76efd04 Binary files /dev/null and b/docs/screenshots/split-dark.png differ diff --git a/docs/screenshots/split.png b/docs/screenshots/split.png new file mode 100644 index 0000000..273abd2 Binary files /dev/null and b/docs/screenshots/split.png differ diff --git a/src/App.tsx b/src/App.tsx index aa46607..275c192 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { lazy, Suspense, useCallback, useDeferredValue, useEffect, useMemo, useR import { invoke } from "@tauri-apps/api/core"; import { readTextFile } from "@tauri-apps/plugin-fs"; import { message } from "@tauri-apps/plugin-dialog"; -import { ListTree, Settings as SettingsIcon } from "lucide-react"; +import { Columns2, ListTree, Rows2, Square, Settings as SettingsIcon } from "lucide-react"; import type { QuickOpenFile } from "@/components/quickopen/QuickOpenDialog"; import type { SettingsSection } from "@/components/settings/SettingsDialog"; import { OutlinePanel } from "@/components/document/OutlinePanel"; @@ -10,20 +10,25 @@ import { matchShortcut, parseShortcut } from "@/lib/shortcuts"; const QuickOpenDialog = lazy(() => import("@/components/quickopen/QuickOpenDialog")); import { Button } from "@/components/ui/button"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { TooltipProvider } from "@/components/ui/tooltip"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; import { ExplorerSidebar, type ResolvedCrossLink, } from "@/components/explorer/ExplorerSidebar"; -import { EmptyDocument } from "@/components/document/EmptyDocument"; import { PathBreadcrumb } from "@/components/document/PathBreadcrumb"; -import { TabBar } from "@/components/document/TabBar"; -import { TabScrollPane } from "@/components/document/TabScrollPane"; +import { PaneView } from "@/components/document/PaneView"; const SettingsDialog = lazy(() => import("@/components/settings/SettingsDialog")); import { useLibrary } from "@/hooks/useLibrary"; -import { useTabs } from "@/hooks/useTabs"; +import { usePanes } from "@/hooks/usePanes"; +import type { SplitMode } from "@/lib/storage"; import { useTheme } from "@/hooks/useTheme"; import { useViewSettings } from "@/hooks/useViewSettings"; import { useSidebarState } from "@/hooks/useSidebarState"; @@ -49,9 +54,10 @@ import "@/styles/code-theme.css"; function App() { const library = useLibrary(); const viewSettings = useViewSettings(); - const tabs = useTabs({ + const panes = usePanes({ autoReloadOnExternalChange: viewSettings.settings.autoReloadOnExternalChange, }); + const tabs = panes.activePane; const sidebar = useSidebarState(viewSettings.settings.defaultFolderState); const pinned = usePinned(); useTheme(viewSettings.settings.colorScheme, viewSettings.settings.accentColor); @@ -62,10 +68,17 @@ function App() { const [settingsSection, setSettingsSection] = useState(); const [quickOpen, setQuickOpen] = useState(false); const [quickOpenMounted, setQuickOpenMounted] = useState(false); - const [activeScrollEl, setActiveScrollEl] = useState(null); + const [scrollElByPane, setScrollElByPane] = useState<[HTMLElement | null, HTMLElement | null]>([ + null, + null, + ]); + const activeScrollEl = scrollElByPane[panes.layout.activePane]; - const handleActiveRefChange = useCallback((el: HTMLElement | null) => { - setActiveScrollEl(el); + const handleScrollElChange0 = useCallback((el: HTMLElement | null) => { + setScrollElByPane(([_, b]) => [el, b]); + }, []); + const handleScrollElChange1 = useCallback((el: HTMLElement | null) => { + setScrollElByPane(([a, _]) => [a, el]); }, []); const toggleOutline = useCallback(() => { @@ -168,10 +181,14 @@ function App() { // Auto-open project.homepage once per workspace per session: only fires on // the first time the active scan finishes with no tabs open in that root, // so closing the homepage tab doesn't keep reopening it on workspace switch. + // Homepage auto-open targets pane 0 specifically, regardless of which + // pane is currently active. Pane 0 is the canonical "main" pane and + // is the only one rendered when split is off. const autoOpenedHomepageRef = useRef>(new Set()); - const tabsHydrated = tabs.hydrated; - const tabsList = tabs.tabs; - const tabsOpenInNew = tabs.openInNew; + const pane0 = panes.panes[0]; + const tabsHydrated = pane0.hydrated; + const tabsList = pane0.tabs; + const tabsOpenInNew = pane0.openInNew; useEffect(() => { if (!tabsHydrated) return; if (!library.activeRoot) return; @@ -243,6 +260,54 @@ function App() { return () => window.removeEventListener("keydown", onKey); }, [quickOpenShortcut]); + // Split-pane keyboard shortcuts. Cmd+\ toggles horizontal, Cmd+Shift+\ + // toggles vertical, Cmd+1 / Cmd+2 focus pane 0 / pane 1. All no-op for + // form inputs so they don't fire while the user is typing. + const splitHorizontalShortcut = useMemo(() => parseShortcut("Mod+\\"), []); + const splitVerticalShortcut = useMemo(() => parseShortcut("Mod+Shift+\\"), []); + const focusPane0Shortcut = useMemo(() => parseShortcut("Mod+1"), []); + const focusPane1Shortcut = useMemo(() => parseShortcut("Mod+2"), []); + const currentSplit = panes.layout.split; + const panesSetSplit = panes.setSplit; + const panesFocusPane = panes.focusPane; + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) { + return; + } + if (splitHorizontalShortcut && matchShortcut(e, splitHorizontalShortcut)) { + e.preventDefault(); + panesSetSplit(currentSplit === "horizontal" ? "off" : "horizontal"); + return; + } + if (splitVerticalShortcut && matchShortcut(e, splitVerticalShortcut)) { + e.preventDefault(); + panesSetSplit(currentSplit === "vertical" ? "off" : "vertical"); + return; + } + if (focusPane0Shortcut && matchShortcut(e, focusPane0Shortcut)) { + e.preventDefault(); + panesFocusPane(0); + return; + } + if (focusPane1Shortcut && matchShortcut(e, focusPane1Shortcut)) { + e.preventDefault(); + panesFocusPane(1); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [ + splitHorizontalShortcut, + splitVerticalShortcut, + focusPane0Shortcut, + focusPane1Shortcut, + currentSplit, + panesSetSplit, + panesFocusPane, + ]); + useEffect(() => { if (quickOpenMounted) return; const idle = @@ -438,6 +503,7 @@ function App() { selectedPath={tabs.activeTab?.path} onSelectFile={tabs.openInActive} onOpenInNewTab={tabs.openInNew} + onOpenInOtherPane={panes.openInOtherPane} /> @@ -447,6 +513,39 @@ function App() { )}
+ v && panes.setSplit(v as SplitMode)} + variant="outline" + spacing={4} + aria-label="Split layout" + > + + + + + + + + + + {tabs.activeTab && (