mirror of
https://github.com/bfloydd/streams.git
synced 2026-07-22 05:49:02 +00:00
add adrs and functionality
This commit is contained in:
parent
748bb00644
commit
5c489e3bb3
3 changed files with 128 additions and 12 deletions
63
adr/0001-leaf-selection-behavior.md
Normal file
63
adr/0001-leaf-selection-behavior.md
Normal 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.
|
||||
42
adr/0002-ribbon-click-behavior.md
Normal file
42
adr/0002-ribbon-click-behavior.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue