From 5c489e3bb3d594e506af6c8d791265cba18ffa1f Mon Sep 17 00:00:00 2001 From: Ben Floyd Date: Wed, 25 Mar 2026 08:52:38 -0600 Subject: [PATCH] add adrs and functionality --- adr/0001-leaf-selection-behavior.md | 63 +++++++++++++++++++ adr/0002-ribbon-click-behavior.md | 42 +++++++++++++ .../file-operations/LeafSelectionService.ts | 35 +++++++---- 3 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 adr/0001-leaf-selection-behavior.md create mode 100644 adr/0002-ribbon-click-behavior.md diff --git a/adr/0001-leaf-selection-behavior.md b/adr/0001-leaf-selection-behavior.md new file mode 100644 index 0000000..d80c05e --- /dev/null +++ b/adr/0001-leaf-selection-behavior.md @@ -0,0 +1,63 @@ +# ADR-0001: Leaf selection behavior for stream navigation + +**Date:** 2026-03-25 +**Status:** Accepted +**Affects:** `LeafSelectionService.ts`, `RibbonService.ts`, `OpenTodayStreamCommand.ts` + +## Context + +When a user clicks a ribbon icon (or runs a command) to open a stream's daily note, the plugin must decide *which* workspace tab (leaf) to open the file in. Obsidian's workspace has three areas: + +- **Main editor area** (`workspace.rootSplit`) — the central pane(s) where notes are edited +- **Left sidebar** (`workspace.leftSplit`) — file explorer, bookmarks, etc. +- **Right sidebar** (`workspace.rightSplit`) — backlinks, outgoing links, etc. + +Prior to this decision, the plugin had several issues: + +1. **Pinned tabs were overwritten** — clicking a ribbon icon while a pinned tab was active would load the stream note into that pinned tab, violating user intent. +2. **New tabs were always created** — when the active tab couldn't be reused (e.g., it was pinned), the plugin created a brand-new tab rather than reusing an existing unpinned one, leading to tab proliferation. +3. **Side panes could be targeted** — the leaf selection logic didn't distinguish between main editor leaves and sidebar leaves, so a stream note could open inside a sidebar panel. + +## Decision + +### 1. Never navigate into pinned tabs + +All leaf selection paths check `(leaf as any).pinned === true` before reusing a leaf. If the active leaf is pinned, it is skipped entirely. + +### 2. Prefer reusing an existing unpinned tab over creating a new one + +When the active leaf can't be used (pinned or in a side pane), `findNextUnpinnedLeaf()` scans the workspace for the first unpinned, eligible leaf in the main editor area. A new tab is only created as a last resort when no candidate is found. + +### 3. Restrict all leaf selection to the main editor area + +Every leaf candidate is checked via `leaf.getRoot() === app.workspace.rootSplit`. Leaves in left/right sidebars are never selected for navigation. This applies to: + +- `findNextUnpinnedLeaf()` — only scans main editor leaves +- `reuseCurrentLeaf()` — won't reuse the active leaf if it's in a sidebar +- `selectOrCreateLeaf()` — same guard +- `selectLeafForFile()` — won't match existing leaves in sidebars + +### 4. Ctrl/Cmd+click forces a new tab + +Ribbon icon callbacks capture the `MouseEvent`. When `ctrlKey` (Windows/Linux) or `metaKey` (macOS) is held, a fresh leaf is created via `app.workspace.getLeaf('tab')` and passed as `targetLeaf`, bypassing all reuse logic. This matches standard Obsidian/browser conventions. + +## Leaf selection priority + +For a normal click, the selection order is: + +1. **Active leaf** — if it's unpinned, in the main editor area, and passes any view-type filter +2. **Existing leaf with same file** — (for `selectLeafForFile` only) if already open in the main editor +3. **Next unpinned leaf** — first unpinned leaf found in the main editor area +4. **New tab** — created via `getLeaf('tab')` as a last resort + +For Ctrl/Cmd+click: + +1. **New tab** — always, via `getLeaf('tab')` passed as `targetLeaf` + +## Consequences + +- **Pinned tabs are always respected.** Users can pin a tab and know it won't be overwritten by ribbon navigation. +- **Tab count stays manageable.** Reusing unpinned tabs prevents accumulation of redundant tabs. +- **Sidebar integrity is preserved.** File explorer, backlinks, and other sidebar views are never replaced with stream content. +- **Ctrl+click provides an escape hatch.** Users who *do* want a new tab can explicitly request one. +- **Relies on internal API.** The `pinned` property on `WorkspaceLeaf` is not part of Obsidian's public TypeScript API. This is a commonly used internal property but could change in future Obsidian versions. diff --git a/adr/0002-ribbon-click-behavior.md b/adr/0002-ribbon-click-behavior.md new file mode 100644 index 0000000..9b732e6 --- /dev/null +++ b/adr/0002-ribbon-click-behavior.md @@ -0,0 +1,42 @@ +# ADR-0002: Ribbon click behavior + +**Date:** 2026-03-25 +**Status:** Accepted +**Affects:** `RibbonService.ts`, `OpenTodayStreamCommand.ts`, `OpenTodayPrimaryStreamCommand.ts` + +## Context + +Users interact with stream ribbon icons to quickly open a stream's daily note. Before this change, every click behaved identically — there was no way to control *how* the note opened (reuse a tab vs. open a new one) at click time. Users who wanted to open the same stream in a second tab had no mechanism to do so from the ribbon. + +Standard Obsidian and browser conventions use **Ctrl+click** (or **Cmd+click** on macOS) to force-open a link in a new tab. + +## Decision + +### Normal click (no modifier) + +Follows the reuse-first strategy defined in [ADR-0001](0001-leaf-selection-behavior.md): + +1. Reuse the active leaf if it's unpinned and in the main editor area +2. Otherwise find an existing unpinned leaf in the main editor area +3. Create a new tab only as a last resort + +This respects the user's `reuseCurrentTab` setting and keeps tab count manageable. + +### Ctrl+click / Cmd+click + +Always opens in a **new tab**, bypassing all reuse logic: + +1. The ribbon callback captures the `MouseEvent` +2. If `evt.ctrlKey` (Windows/Linux) or `evt.metaKey` (macOS) is `true`, a fresh leaf is created via `app.workspace.getLeaf('tab')` +3. This leaf is passed as `targetLeaf` to the command, which forwards it to `openStreamDate()` +4. When `targetLeaf` is provided, `LeafSelectionService` is not consulted at all + +### Implementation + +Both ribbon callbacks (`createAllStreamsIcon` and `createStreamIcons`) were updated from `() => { ... }` to `(evt: MouseEvent) => { ... }`. The `OpenTodayStreamCommand` constructor was extended with an optional `targetLeaf` parameter (matching `OpenTodayPrimaryStreamCommand`, which already had one). + +## Consequences + +- **Matches platform conventions.** Ctrl/Cmd+click for "open in new tab" is muscle memory for most users. +- **No new settings required.** The modifier key approach is discoverable and doesn't add UI complexity. +- **Commands (palette) are unaffected.** Only ribbon icon clicks receive the mouse event; command palette invocations continue to use the standard reuse-first strategy. diff --git a/src/slices/file-operations/LeafSelectionService.ts b/src/slices/file-operations/LeafSelectionService.ts index e8dbcca..225a584 100644 --- a/src/slices/file-operations/LeafSelectionService.ts +++ b/src/slices/file-operations/LeafSelectionService.ts @@ -15,20 +15,30 @@ export class LeafSelectionService { } /** - * Finds the next unpinned leaf in the workspace that can be reused. + * Checks whether a leaf belongs to the main editor area (rootSplit), + * as opposed to left/right side panes. + */ + private static isInMainEditorArea(app: App, leaf: WorkspaceLeaf): boolean { + return leaf.getRoot() === app.workspace.rootSplit; + } + + /** + * Finds the next unpinned leaf in the main editor area that can be reused. * Mimics Obsidian's native behavior of selecting an existing tab rather - * than always creating a new one. + * than always creating a new one. Only considers leaves in the main + * editor area, never side panes. * @param app - Obsidian app instance * @param viewTypeFilter - Optional filter to check if a leaf's view type is suitable - * @returns An unpinned leaf, or a new tab if none found + * @returns An unpinned leaf in the main area, or a new tab if none found */ private static findNextUnpinnedLeaf( app: App, viewTypeFilter?: (viewType: string) => boolean ): WorkspaceLeaf | null { - // Scan all root-level leaves for an unpinned candidate + // Scan only main editor leaves for an unpinned candidate const candidates: WorkspaceLeaf[] = []; app.workspace.iterateRootLeaves((leaf: WorkspaceLeaf) => { + if (!this.isInMainEditorArea(app, leaf)) return; if (!this.isLeafPinned(leaf)) { if (!viewTypeFilter || viewTypeFilter(leaf.view.getViewType())) { candidates.push(leaf); @@ -40,7 +50,7 @@ export class LeafSelectionService { return candidates[0]; } - // No unpinned leaf found — create a new tab as last resort + // No unpinned leaf found in main area — create a new tab as last resort try { return app.workspace.getLeaf('tab'); } catch (error) { @@ -73,11 +83,11 @@ export class LeafSelectionService { */ private static reuseCurrentLeaf(app: App): WorkspaceLeaf | null { const activeLeaf = app.workspace.activeLeaf; - if (activeLeaf && !this.isLeafPinned(activeLeaf)) { + if (activeLeaf && !this.isLeafPinned(activeLeaf) && this.isInMainEditorArea(app, activeLeaf)) { return activeLeaf; } - // Active leaf is pinned — find the next unpinned leaf instead + // Active leaf is pinned or in a side pane — find the next unpinned leaf instead return this.findNextUnpinnedLeaf(app); } @@ -90,14 +100,14 @@ export class LeafSelectionService { ): WorkspaceLeaf | null { const activeLeaf = app.workspace.activeLeaf; - if (activeLeaf && !this.isLeafPinned(activeLeaf)) { + if (activeLeaf && !this.isLeafPinned(activeLeaf) && this.isInMainEditorArea(app, activeLeaf)) { const viewType = activeLeaf.view.getViewType(); if (!viewTypeFilter || viewTypeFilter(viewType)) { return activeLeaf; } } - // Active leaf is pinned or filtered out — find the next unpinned leaf + // Active leaf is pinned, filtered out, or in a side pane — find the next unpinned leaf return this.findNextUnpinnedLeaf(app, viewTypeFilter); } @@ -113,10 +123,11 @@ export class LeafSelectionService { file: TFile, reuseCurrentTab: boolean ): WorkspaceLeaf | null { - // First, try to find existing leaf with this file + // First, try to find existing leaf with this file (only in main editor area) const existingLeaf = app.workspace.getLeavesOfType('markdown') .find(leaf => { try { + if (!this.isInMainEditorArea(app, leaf)) return false; const view = leaf.view as MarkdownView; const viewFile = view?.file; if (!viewFile || !file) return false; @@ -136,12 +147,12 @@ export class LeafSelectionService { // If not found and reuseCurrentTab is enabled, try to reuse current leaf if (reuseCurrentTab) { const activeLeaf = app.workspace.activeLeaf; - if (activeLeaf && !this.isLeafPinned(activeLeaf)) { + if (activeLeaf && !this.isLeafPinned(activeLeaf) && this.isInMainEditorArea(app, activeLeaf)) { return activeLeaf; } } - // Find the next unpinned leaf, or create a new tab as last resort + // Find the next unpinned leaf in the main area, or create a new tab as last resort return this.findNextUnpinnedLeaf(app); } }