add adrs and functionality

This commit is contained in:
Ben Floyd 2026-03-25 08:52:38 -06:00
parent 748bb00644
commit 5c489e3bb3
3 changed files with 128 additions and 12 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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);
}
}