Compare commits

...

54 commits
1.2.5 ... main

Author SHA1 Message Date
Ben Floyd
ab2e58bd85 v1.3.4 2026-07-02 19:45:16 -06:00
Ben Floyd
a9cde02c78 streambar layout adjust 2026-07-02 19:42:58 -06:00
Ben Floyd
37eeb11247 layout adjust streams bar 2026-07-02 18:27:57 -06:00
Ben Floyd
f1afd09bda style adjust 2026-07-02 18:25:48 -06:00
Ben Floyd
f85a795dcc pill style for desktop 2026-07-02 16:25:52 -06:00
Ben Floyd
4e109b0798 desktop newline 2026-07-02 16:16:53 -06:00
Ben Floyd
d7ce138f91 remove bar style 2026-07-02 16:06:18 -06:00
Ben Floyd
24c52fa626 use chevron instead of ascii 2026-07-02 15:51:23 -06:00
Ben Floyd
a120018138 layout updates 2026-07-02 11:57:49 -06:00
Ben Floyd
ee989d755c fix comment 2026-07-02 11:49:01 -06:00
Ben Floyd
2766228af4 layout fix 2026-07-02 11:45:46 -06:00
Ben Floyd
4577611ce5 improve layout 2026-07-02 11:20:05 -06:00
Ben Floyd
8eebf31015 remove header spacing on pdf 2026-07-01 20:41:37 -06:00
Ben Floyd
335e05567f v1.3.3 2026-06-03 16:26:00 -06:00
Ben Floyd
cd83a82c58 add attestations 2026-06-03 16:25:27 -06:00
Ben Floyd
74cf520b4a add skill 2026-06-03 16:19:22 -06:00
Ben Floyd
c91b2a1460 v1.3.2 2026-06-03 16:13:17 -06:00
Ben Floyd
5dfdc808ea remove :has 2026-06-03 16:12:55 -06:00
Ben Floyd
6d0d067071 remove !important 2026-06-03 16:11:00 -06:00
Ben Floyd
3866e55779 no more builtin-modules 2026-06-03 16:09:04 -06:00
Ben Floyd
f6f92ce247 v1.3.1 2026-06-03 10:51:55 -06:00
Ben Floyd
654ba6f8f7 remove unused providesApi 2026-06-03 10:51:30 -06:00
Ben Floyd
ae909be484 date mismatch fix 2026-06-03 10:49:49 -06:00
Ben Floyd
5c489e3bb3 add adrs and functionality 2026-03-25 08:52:38 -06:00
Ben Floyd
748bb00644 no longer override pinned tab. ctrl click also open new tab 2026-03-23 07:05:35 -06:00
Ben Floyd
7bedfbdd9c v1.3.0 2026-03-01 20:38:09 -07:00
Ben Floyd
0a23247f59 reorder 2026-03-01 20:37:33 -07:00
Ben Floyd
2c7bbfd1fa dont break when {} are missing 2026-03-01 20:28:06 -07:00
Ben Floyd
2672340764 style the button plainly 2026-03-01 20:17:46 -07:00
Ben Floyd
d82be7a37a remove redundant text 2026-03-01 20:15:17 -07:00
Ben Floyd
c751370c60 better error checking 2026-03-01 20:12:08 -07:00
Ben Floyd
46d2ed166c update text 2026-03-01 20:09:42 -07:00
Ben Floyd
1f3999373c support other paths 2026-03-01 19:48:30 -07:00
Ben Floyd
d821571b34 v1.2.9 2026-02-28 17:26:35 -07:00
Ben Floyd
8565102a2e stack the input below 2026-02-28 17:25:59 -07:00
Ben Floyd
140aa5075f no longer dismiss settings when adjusting 2026-02-28 17:21:37 -07:00
Ben Floyd
65626b04b0 change toggle to button 2026-02-28 16:40:24 -07:00
Ben Floyd
025403010b custom dates for files 2026-02-28 16:31:15 -07:00
Ben Floyd
47079fa96a v1.2.8 2026-01-22 16:20:03 -07:00
Ben Floyd
1639c47070 common height 2026-01-22 16:19:58 -07:00
Ben Floyd
f2d0d17248 v1.2.7 2026-01-22 11:07:16 -07:00
Ben Floyd
20ddade42b fix test for primary stream 2026-01-22 11:06:57 -07:00
Ben Floyd
4649080bad same changes for modern 2026-01-22 11:05:28 -07:00
Ben Floyd
26317ba5ba adjust layout for new is-phone changes 2026-01-22 11:01:24 -07:00
Ben Floyd
cff316754e home button goes to stream home 2026-01-22 08:58:47 -07:00
Ben Floyd
c39eff0189 add primary stream concept and remove selected stream 2026-01-12 17:14:53 -07:00
Ben Floyd
4a982b85bc extract interface 2026-01-07 19:36:07 -07:00
Ben Floyd
840f9854c6 fixing toggles 2025-12-10 10:48:12 -07:00
Ben Floyd
6a20ed1c50 fix test build error 2025-12-09 11:17:12 -07:00
Ben Floyd
5ac2fb2227 v1.2.6 2025-12-09 11:14:23 -07:00
Ben Floyd
2c21e67c6d add icon selector 2025-12-09 11:13:13 -07:00
Ben Floyd
c6787712aa cleanup code 2025-12-09 10:44:20 -07:00
Ben Floyd
490b368446 remove activestream remanants 2025-12-09 10:32:16 -07:00
Ben Floyd
a69e1e57a4 dont show streamsbar on empty 2025-12-09 10:23:57 -07:00
49 changed files with 1919 additions and 671 deletions

View file

@ -0,0 +1,41 @@
---
name: managing-obsidian-plugin
description: Manages the development, refactoring, and maintenance of an Obsidian plugin coding project. Use when the user requests feature additions, bug fixes, UI improvements, or refactoring in the Obsidian plugin codebase.
---
# Managing Obsidian Plugin
## When to use this skill
- Adding new features or settings to the Obsidian plugin.
- Refactoring existing plugin components (e.g., UI, logic).
- Fixing bugs or testing plugin functionality.
- Managing Obsidian API interactions.
## Workflow
### 1. Plan & Analyze
- [ ] Read relevant workspace files to locate the affected components (e.g., `main.ts`, view files, or service classes).
- [ ] Check `manifest.json` for required plugin version bumps if deploying a new release.
- [ ] Identify dependencies between UI components, styles, and core logic.
### 2. Validate
- [ ] Validate changes against existing test suites using relevant testing scripts.
- [ ] Check if the changes necessitate an update to settings menus or serialized data structures.
### 3. Execute
- [ ] Apply code changes using progressive and isolated module updates.
- [ ] Run `npm run build` to verify compilation without errors.
## Instructions
- Keep interactions with the Obsidian API well-isolated and properly typed.
- Prioritize updating or creating unit tests alongside feature development to verify logic safely.
- If a build script fails, do not proceed blindly; troubleshoot the specific error output before continuing.
## Architecture
- We use a vertical slice architecture.
- Strict adherance to cleancode rules.
- Use the provided .agent/skills/obsidian-plugin/SKILL.md as the main source of truth for coding standards and best practices.
- Use ADRs to document design decisions in adr/. Refer to the ADRs folder for more information and ensure compliance with existing ADRs.
## Resources
- None

47
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: Release Obsidian Plugin
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npm run build
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-path: |
dist/main.js
dist/manifest.json
dist/styles.css
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
dist/main.js
dist/manifest.json
dist/styles.css
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

57
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,57 @@
# Contributing to Streams
Welcome! We gladly accept contributions from the community. If you'd like to help improve Streams, follow the steps below.
## How to Contribute
1. **Fork the repository** and clone it to your local machine.
2. **Install dependencies** by running `npm install`.
3. **Create a new branch** for your feature or bug fix (`git checkout -b feature/your-feature-name`).
4. **Make your changes**. You can use `npm run dev` to watch for changes and compile the plugin, then test them locally in your Obsidian vault.
5. **Commit your changes** with clear, descriptive commit messages.
6. **Push to your fork** and open a Pull Request against our `main` branch.
All Pull Requests will be reviewed and merged by the maintainer.
---
## Maintainer Guide: Releasing a New Version
*(The following instructions are for project maintainers only)*
The release process is fully automated via GitHub Actions. **You do not need to build the plugin or generate attestations manually.**
Whenever a new tag is pushed, a background GitHub Action will automatically:
- **Build the plugin** (`main.js`, `styles.css`) from source.
- **Generate cryptographic artifact attestations** for security and provenance.
- **Publish a GitHub Release** and attach the built artifacts.
Before triggering a release, **you must bump the version numbers**:
1. Run `npm run version` (or manually update `manifest.json` and `versions.json`).
2. Commit and push the changes to the `main` branch.
Once the versions are bumped, you can trigger the automated release using either the GitHub UI or the command line.
### Option 1: Using the GitHub Releases UI (Recommended)
This is the simplest method if you prefer a visual interface.
1. Navigate to the **Releases** page on your GitHub repository.
2. Click the **Draft a new release** button.
3. In the "Choose a tag" dropdown, type your new version tag (e.g., `1.0.6`) and click **Create new tag: 1.0.6 on publish**.
4. (Optional) Fill out the release title and notes.
5. Click **Publish release**.
> **Note:** As soon as you click publish, GitHub will trigger the Action in the background. Navigate to the **Actions** tab to watch it build, sign, and attach the files to your newly drafted release!
### Option 2: Using the Command Line (CLI)
If you prefer terminal commands, you can push the tag directly via Git.
1. Create a local tag for your new version:
```bash
git tag 1.0.6
```
2. Push the tag to GitHub:
```bash
git push origin main --tags
```
> **Note:** Pushing the tag automatically triggers the GitHub Action. The Action will build the assets and create the GitHub Release for you automatically. You can edit the release notes in the GitHub UI afterward if needed.

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

@ -0,0 +1,40 @@
# ADR-0003: Stream bar date synchronization on external navigation
**Date:** 2026-03-25
**Status:** Accepted
**Affects:** `StreamsBarComponent.ts`
## Context
The stream bar displays the currently viewed date and provides calendar navigation within a stream. When a user opens a stream file via the plugin (ribbon icon, command palette, calendar click), the plugin explicitly calls `DateStateManager.setCurrentDate()` as part of the `openStreamDate()` flow, keeping the bar in sync.
However, users can also navigate to stream files through **external** means:
- Clicking a file in the file explorer
- Following an internal link from another note
- Using Obsidian's Quick Switcher
- Navigating browser history (back/forward)
In these cases, the plugin's `openStreamDate()` is never called. The `StreamsBarComponent`'s `file-open` handler detected the correct stream via `updateStreamContext()`, but **never extracted the date from the filename**. This left the stream bar showing a stale date from whatever was previously viewed.
## Decision
`updateStreamContext()` now always extracts the date from the opened file's basename when the file is identified as belonging to a stream. The date is parsed from the `YYYY-MM-DD` prefix pattern and pushed to `DateStateManager.setCurrentDate()`.
```typescript
// Extract date from filename (e.g., "2026-03-25" from "2026-03-25.md")
const match = file.basename.match(/^\d{4}-\d{2}-\d{2}/);
if (match) {
const [year, month, day] = match[0].split('-').map(n => parseInt(n, 10));
this.dateStateManager.setCurrentDate(new Date(year, month - 1, day));
}
```
This runs on every `file-open` event where the file matches a stream, regardless of how the navigation was initiated.
## Consequences
- **Stream bar stays in sync** regardless of how the user navigated to the file.
- **Calendar highlights the correct date**, enabling adjacent-day navigation from the right starting point.
- **No redundant updates**`DateStateManager` already deduplicates if the date hasn't changed, so plugin-initiated navigation (which also calls `setCurrentDate`) does not cause double renders.
- **Assumes `YYYY-MM-DD` prefix** in filenames. Files that belong to a stream folder but don't follow this naming convention will not update the date display.

View file

@ -1,6 +1,6 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { builtinModules } from "module";
import fs from "fs";
import path from "path";
@ -29,7 +29,7 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
...builtinModules],
format: "cjs",
target: "es2018",
logLevel: "info",

49
main.ts
View file

@ -39,13 +39,13 @@ export default class StreamsPlugin extends Plugin implements StreamsAPI {
const loadedData = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData);
// Migration: ensure barStyle exists
if (!this.settings.barStyle) {
this.settings.barStyle = 'default';
// Migration: ensure primaryStreamId exists
if (this.settings.primaryStreamId === undefined) {
this.settings.primaryStreamId = null;
await this.saveSettings();
}
// Migration: ensure encryptThisStream exists for existing streams
// Migration: ensure encryptThisStream, disabled, and merge 'folder' into 'dateFormat' for existing streams
let needsSave = false;
for (const stream of this.settings.streams) {
if (stream.encryptThisStream === undefined) {
@ -56,13 +56,50 @@ export default class StreamsPlugin extends Plugin implements StreamsAPI {
stream.disabled = false;
needsSave = true;
}
// Ensure dateFormat has a default if missing
if (stream.dateFormat === undefined) {
stream.dateFormat = 'YYYY-MM-DD';
needsSave = true;
}
// Migration: merge 'folder' into 'dateFormat' and delete 'folder'
if (stream.folder !== undefined) {
let newDateFormat = stream.dateFormat;
if (!newDateFormat.includes('{') && !newDateFormat.includes('}')) {
newDateFormat = `{${newDateFormat}}`;
}
if (stream.folder.trim() !== '') {
// Clean folder slashes
let cleanFolder = stream.folder.trim().replace(/\/+$/, '');
// If the legacy dateFormat was an absolute path, it overrides the folder
// (as per previous backward compatibility logic). So only prefix if it's not absolute.
if (!stream.dateFormat.startsWith('/')) {
stream.dateFormat = `${cleanFolder}/${newDateFormat}`;
} else {
stream.dateFormat = newDateFormat;
}
} else {
stream.dateFormat = newDateFormat;
}
// Remove the deprecated property
delete stream.folder;
needsSave = true;
}
}
// Cleanup: Remove legacy activeStreamId
// Cleanup: Remove legacy activeStreamId and calendarCompactState
if ((this.settings as any).activeStreamId !== undefined) {
delete (this.settings as any).activeStreamId;
needsSave = true;
}
if ((this.settings as any).calendarCompactState !== undefined) {
delete (this.settings as any).calendarCompactState;
needsSave = true;
}
if (needsSave) {
await this.saveSettings();
@ -150,8 +187,6 @@ export default class StreamsPlugin extends Plugin implements StreamsAPI {
return serviceRegistry.api?.getVersion() || { version: '1.0.0', minAppVersion: '0.15.0', name: 'Streams', id: 'streams' };
}
// Internal Services (for plugin functionality)
getFileOperationsService(): FileOperationsService | undefined {
return serviceRegistry.fileOperations;

View file

@ -1,17 +1,16 @@
{
"id": "streams",
"name": "Streams",
"version": "1.2.5",
"version": "1.3.4",
"minAppVersion": "0.15.0",
"description": "Create and manage multiple Daily Note streams. Stream <-> Daily Notes <-> Backlink.",
"author": "Floyd",
"authorUrl": "http://floyd.pro",
"authorUrl": "https://floyd.pro",
"fundingUrl": {
"GitHub Sponsor": "https://github.com/sponsors/bfloydd",
"Buy Me a Coffee": "https://buymeacoffee.com/floydpro",
"Venmo": "https://venmo.com/floydpro?txn=pay",
"PayPal": "https://paypal.me/bfloydd"
},
"isDesktopOnly": false,
"providesApi": true
"isDesktopOnly": false
}

18
package-lock.json generated
View file

@ -1,6 +1,6 @@
{
"name": "streams",
"version": "1.0.0",
"version": "1.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -13,7 +13,6 @@
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
@ -2376,19 +2375,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -6502,4 +6488,4 @@
}
}
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "streams",
"version": "1.0.0",
"version": "1.3.4",
"description": "Streams plugin for Obsidian",
"main": "main.js",
"scripts": {
@ -19,7 +19,6 @@
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
@ -28,4 +27,4 @@
"tslib": "2.4.0",
"typescript": "4.7.4"
}
}
}

View file

@ -34,11 +34,11 @@ export interface SystemLimitsConfiguration {
export interface DefaultSettingsConfiguration {
readonly streams: readonly [];
readonly primaryStreamId: string | null;
readonly showStreamsBarComponent: boolean;
readonly reuseCurrentTab: boolean;
readonly debugLoggingEnabled: boolean;
readonly barStyle: 'default';
}
// Main configuration interface
@ -73,7 +73,6 @@ export class ConfigurationService extends BaseSliceService implements Configurat
view: {
CREATE_FILE_VIEW_TYPE: 'streams-create-file-view',
CALENDAR_ENABLED_VIEW_TYPES: [
'empty',
'file-explorer',
'search',
'graph',
@ -105,11 +104,11 @@ export class ConfigurationService extends BaseSliceService implements Configurat
defaults: {
streams: [],
primaryStreamId: null,
showStreamsBarComponent: true,
reuseCurrentTab: false,
debugLoggingEnabled: false,
barStyle: 'default'
debugLoggingEnabled: false
} as const
};
@ -221,11 +220,11 @@ export class ConfigurationService extends BaseSliceService implements Configurat
getDefaultSettings(): StreamsSettings {
return {
streams: [...this.config.defaults.streams],
primaryStreamId: this.config.defaults.primaryStreamId,
showStreamsBarComponent: this.config.defaults.showStreamsBarComponent,
reuseCurrentTab: this.config.defaults.reuseCurrentTab,
debugLoggingEnabled: this.config.defaults.debugLoggingEnabled,
barStyle: this.config.defaults.barStyle
debugLoggingEnabled: this.config.defaults.debugLoggingEnabled
};
}
}

View file

@ -1,5 +1,7 @@
import { TFile } from 'obsidian';
import { Stream } from './types';
import { getStreamBaseFolder } from '../slices/file-operations/streamUtils';
import { FileUtils } from './utils/FileUtils';
export class StreamContextService {
/**
@ -30,13 +32,9 @@ export class StreamContextService {
* @param stream The stream to check against.
*/
public isFileInStream(file: TFile, stream: Stream): boolean {
if (!file.path || !stream.folder) return false;
if (!file.path || !stream.dateFormat) return false;
// Normalize paths for comparison (remove trailing slashes if any, though obsidian paths usually don't have them)
const streamFolder = stream.folder.replace(/\/$/, '');
// Exact match (file is the folder? unlikely) or subdirectory match
// We verify it starts with "folder/"
return file.path.startsWith(streamFolder + '/');
const streamFolder = getStreamBaseFolder(stream);
return FileUtils.fileBelongsToStream(file.path, streamFolder);
}
}

View file

@ -21,10 +21,10 @@ export const CALENDAR_ENABLED_VIEW_TYPES = [
*/
export const DEFAULT_SETTINGS = {
streams: [],
primaryStreamId: null,
showStreamsBarComponent: true,
reuseCurrentTab: false,
debugLoggingEnabled: false,
barStyle: 'default' as const
debugLoggingEnabled: false
} as const;

View file

@ -129,4 +129,3 @@ export function getCommandById(app: App, commandId: string): ObsidianCommand | u
export interface ViewWithEmptyStateObserver extends View {
emptyStateObserver?: MutationObserver;
}

View file

@ -21,21 +21,26 @@ export type LucideIcon =
export interface Stream {
id: string;
name: string;
folder: string;
icon: LucideIcon;
showTodayInRibbon: boolean;
addCommand: boolean;
encryptThisStream: boolean; // New field for encryption toggle
disabled: boolean; // New field for disabling streams
dateFormat: string; // New field for flexible date formatting
folder?: string; // DEPRECATED: Migrated to dateFormat
}
export interface StreamsSettings {
streams: Stream[];
/**
* The single "primary" stream used by "Go to primary stream".
* Null means no primary stream is configured.
*/
primaryStreamId: string | null;
showStreamsBarComponent: boolean;
reuseCurrentTab: boolean;
debugLoggingEnabled: boolean; // Whether debug logging is enabled by default
barStyle: 'default' | 'modern'; // Style variant for the streams bar
}
// Specific error data types for better type safety

View file

@ -26,6 +26,7 @@ export const VIEW_CONFIGS: ViewConfig[] = [
showTodayInRibbon: false,
addCommand: false,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
disabled: false
},
extraArgs: [new Date()]
@ -41,6 +42,7 @@ export const VIEW_CONFIGS: ViewConfig[] = [
showTodayInRibbon: false,
addCommand: false,
encryptThisStream: true,
dateFormat: 'YYYY-MM-DD',
disabled: false
}
}

View file

@ -4,6 +4,7 @@ import { StreamsAPI, StreamInfo, PluginVersion } from './StreamsAPI';
import { eventBus, EVENTS } from '../../shared/EventBus';
import { DateUtils } from '../../shared/utils/DateUtils';
import { FileUtils } from '../../shared/utils/FileUtils';
import { getStreamBaseFolder } from '../file-operations/streamUtils';
export class APIService extends PluginAwareSliceService implements StreamsAPI {
async initialize(): Promise<void> {
@ -57,7 +58,7 @@ export class APIService extends PluginAwareSliceService implements StreamsAPI {
if (!folderPath) return [];
return this.filterStreams(stream => {
const streamFolder = FileUtils.normalizePath(stream.folder);
const streamFolder = FileUtils.normalizePath(getStreamBaseFolder(stream));
const searchFolder = FileUtils.normalizePath(folderPath);
return streamFolder === searchFolder ||
@ -102,9 +103,9 @@ export class APIService extends PluginAwareSliceService implements StreamsAPI {
if (!filePath) return null;
// Find streams that match this file path
const matchingStreams = this.getStreams().filter(stream =>
FileUtils.fileBelongsToStream(filePath, stream.folder)
);
const matchingStreams = this.getStreams().filter(stream => {
return FileUtils.fileBelongsToStream(filePath, getStreamBaseFolder(stream));
});
// Return the first match, or null if none found
return matchingStreams.length > 0 ? matchingStreams[0] : null;
@ -122,7 +123,7 @@ export class APIService extends PluginAwareSliceService implements StreamsAPI {
return {
id: stream.id,
name: stream.name,
folder: stream.folder,
folder: getStreamBaseFolder(stream),
icon: stream.icon,
isActive: false // Global active stream concept removed
};

View file

@ -92,11 +92,6 @@ export class ComponentLifecycleManager {
component.updateReuseCurrentTab(settings.reuseCurrentTab);
}
// Refresh bar style for all existing components
if (component && typeof component.refreshBarStyle === 'function') {
component.refreshBarStyle();
}
// Update streams list for all existing components
if (component && typeof component.updateStreamsList === 'function' && settings.streams) {
component.updateStreamsList(settings.streams);
@ -316,6 +311,12 @@ export class ComponentLifecycleManager {
return;
}
// Validate view type before creation
const viewType = leaf.view.getViewType();
if (!this.calendarViewService?.shouldCreateCalendarForViewType(viewType)) {
return;
}
// Get active stream or default stream
const streamToUse = this.getStreamToUse();
if (!streamToUse) {

View file

@ -2,32 +2,20 @@ import { Stream } from '../../shared/types';
import { setIcon } from 'obsidian';
import { DateNavigationService } from './DateNavigationService';
/**
* Interface for plugin that provides settings
*/
interface PluginInterface {
settings: {
barStyle?: 'default' | 'modern';
};
}
/**
* Manages component state for StreamsBarComponent
* Extracted to follow Single Responsibility Principle
*/
export class ComponentStateManager {
private plugin: PluginInterface | null;
private streams: Stream[];
private selectedStream: Stream;
private dateNavigationService: DateNavigationService;
constructor(
plugin: PluginInterface | null,
streams: Stream[],
selectedStream: Stream,
dateNavigationService: DateNavigationService
) {
this.plugin = plugin;
this.streams = streams;
this.selectedStream = selectedStream;
this.dateNavigationService = dateNavigationService;
@ -75,24 +63,6 @@ export class ComponentStateManager {
}
}
/**
* Apply bar style to a component based on settings
*/
applyBarStyle(component: HTMLElement): void {
if (!this.plugin?.settings) {
return;
}
const barStyle = this.plugin.settings.barStyle;
// Remove existing style classes
component.removeClass('modern-style');
// Apply the appropriate style class
if (barStyle === 'modern') {
component.addClass('modern-style');
}
}
/**
* Format the today button text based on the current date

View file

@ -1,7 +1,6 @@
import { App, setIcon, WorkspaceLeaf } from 'obsidian';
import { App, MarkdownView, Notice, setIcon, WorkspaceLeaf } from 'obsidian';
import { Stream } from '../../shared/types';
import { SettingsManager, UIController } from '../../shared/interfaces';
import { OpenTodayCurrentStreamCommand } from '../file-operations/OpenTodayCurrentStreamCommand';
import { getSetting } from '../../shared/obsidian-types';
import { CalendarRenderer } from './CalendarRenderer';
import { StreamSelector } from './StreamSelector';
@ -12,6 +11,10 @@ import { TouchGestureHandler } from './TouchGestureHandler';
import { DocumentEventHandler } from './DocumentEventHandler';
import { ComponentStateManager } from './ComponentStateManager';
import { DateStateManager } from '../../shared/DateStateManager';
import { StreamContextService } from '../../shared/StreamContextService';
import { openStreamDate } from '../file-operations/streamUtils';
import { CREATE_FILE_VIEW_TYPE } from '../file-operations/CreateFileView';
import { CREATE_FILE_VIEW_ENCRYPTED_TYPE } from '../file-operations/CreateFileViewEncrypted';
/**
* Interface for UI element references that will be set by the builder
@ -60,6 +63,7 @@ export class ComponentUIBuilder {
private contentIndicatorService: ContentIndicatorService;
private callbacks: ComponentCallbacks;
private leaf: WorkspaceLeaf;
private streamContextService: StreamContextService;
constructor(
app: App,
@ -87,6 +91,7 @@ export class ComponentUIBuilder {
this.contentIndicatorService = contentIndicatorService;
this.callbacks = callbacks;
this.leaf = leaf;
this.streamContextService = new StreamContextService();
}
/**
@ -221,7 +226,7 @@ export class ComponentUIBuilder {
private setupNavigationControls(navControls: HTMLElement, collapsedView: HTMLElement): void {
const prevDayButton = navControls.createDiv('streams-bar-day-nav prev-day');
prevDayButton.setText('←');
setIcon(prevDayButton, 'chevron-left');
prevDayButton.setAttribute('aria-label', 'Previous day');
this.eventRegistry.register(prevDayButton, 'click', async (e: Event) => {
e.stopPropagation();
@ -237,7 +242,7 @@ export class ComponentUIBuilder {
});
const nextDayButton = navControls.createDiv('streams-bar-day-nav next-day');
nextDayButton.setText('→');
setIcon(nextDayButton, 'chevron-right');
nextDayButton.setAttribute('aria-label', 'Next day');
this.eventRegistry.register(nextDayButton, 'click', async (e: Event) => {
e.stopPropagation();
@ -251,22 +256,51 @@ export class ComponentUIBuilder {
private setupHomeButton(navControls: HTMLElement): void {
const homeButton = navControls.createDiv('streams-bar-home-button');
setIcon(homeButton, 'home');
homeButton.setAttribute('aria-label', 'Go to current stream today');
homeButton.setAttribute('aria-label', 'Go to today in current stream');
this.eventRegistry.register(homeButton, 'click', async (e: Event) => {
e.stopPropagation();
const command = new OpenTodayCurrentStreamCommand(
const streamInView = this.resolveCurrentLeafStream();
if (!streamInView) {
new Notice('No stream found for this view.');
return;
}
await openStreamDate(
this.app,
this.streams,
streamInView,
new Date(),
this.reuseCurrentTab,
(this.settingsManager as any) ?? undefined,
this.stateManager.getActiveStream(),
this.leaf,
this.dateStateManager
);
await command.execute();
});
}
/**
* Resolve the stream that is currently "in view" for this component's leaf.
*
* - Markdown views: infer stream from the open file's path.
* - Streams custom views (CreateFileView / CreateFileViewEncrypted): read stream from view state.
*/
private resolveCurrentLeafStream(): Stream | null {
const view = this.leaf.view;
if (view instanceof MarkdownView) {
return this.streamContextService.getStreamForFile(view.file, this.streams);
}
const viewType = view.getViewType?.();
if (viewType === CREATE_FILE_VIEW_TYPE || viewType === CREATE_FILE_VIEW_ENCRYPTED_TYPE) {
const state = (view as unknown as { getState?: () => unknown }).getState?.();
if (state && typeof state === 'object' && 'stream' in state) {
const stream = (state as { stream?: Stream }).stream;
return stream ?? null;
}
}
return null;
}
private setupSettingsButton(navControls: HTMLElement): void {
const settingsButton = navControls.createDiv('streams-bar-settings-button');
setIcon(settingsButton, 'settings');

View file

@ -4,6 +4,7 @@ import { encryptionDetectionService } from '../../shared/EncryptionDetectionServ
import { configurationService } from '../../shared/ConfigurationService';
import { MeldDetectionService } from '../meld-integration';
import { centralizedLogger } from '../../shared/CentralizedLogger';
import { resolveStreamFilePath } from '../file-operations/streamUtils';
/**
* Content indicator for calendar day display
@ -36,17 +37,7 @@ export class ContentIndicatorService {
* @returns Promise resolving to content indicator
*/
async getContentIndicator(date: Date): Promise<ContentIndicator> {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const fileName = `${year}-${month}-${day}.md`;
const folderPath = this.stream.folder
.split(/[/\\]/)
.filter(Boolean)
.join('/');
const filePath = folderPath ? `${folderPath}/${fileName}` : fileName;
const filePath = resolveStreamFilePath(this.stream, date);
let file = this.app.vault.getAbstractFileByPath(filePath);
let isEncrypted = false;
@ -63,14 +54,14 @@ export class ContentIndicatorService {
// Check if file is encrypted by extension or content
if (!isEncrypted) {
isEncrypted = encryptionDetectionService.isEncryptedFileByPath(file.path) ||
await encryptionDetectionService.isFileEncrypted(this.app, file);
isEncrypted = encryptionDetectionService.isEncryptedFileByPath(file.path) ||
await encryptionDetectionService.isFileEncrypted(this.app, file);
}
const fileSize = file.stat.size;
const fileSizeConfig = configurationService.getFileSizeConfig();
const size = fileSize < fileSizeConfig.SMALL_THRESHOLD ? 'small' :
fileSize < fileSizeConfig.MEDIUM_THRESHOLD ? 'medium' : 'large';
fileSize < fileSizeConfig.MEDIUM_THRESHOLD ? 'medium' : 'large';
// Determine if encrypted file is locked or unlocked
let isLocked = false;
@ -78,11 +69,11 @@ export class ContentIndicatorService {
isLocked = await this.isEncryptedFileLocked(file);
}
return {
exists: true,
size,
isEncrypted,
isLocked
return {
exists: true,
size,
isEncrypted,
isLocked
};
}
@ -99,7 +90,7 @@ export class ContentIndicatorService {
// Try to read the file content to see if it's accessible
const content = await this.app.vault.cachedRead(file);
// If we can read the content and it's not encrypted patterns, it's unlocked
if (content && !encryptionDetectionService.isEncryptedContent(content)) {
return false;

View file

@ -40,6 +40,7 @@ export class StreamDataService implements StreamProvider, FilePathProvider {
showTodayInRibbon: true,
addCommand: true,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
disabled: false
};
}
@ -57,13 +58,7 @@ export class StreamDataService implements StreamProvider, FilePathProvider {
return `${stream.folder}/${fileName}`;
}
/**
* Get the currently active stream
* @deprecated Global active stream is removed. Returns undefined.
*/
getActiveStream(): Stream | undefined {
return undefined;
}
/**
* Get stream to use (default stream)

View file

@ -15,7 +15,7 @@ export class StreamProviderService implements StreamProvider {
* Creates a new StreamProviderService with injected StreamDataService dependency
* @param streamDataService - The service that handles actual stream data operations
*/
constructor(private streamDataService: StreamDataService) {}
constructor(private streamDataService: StreamDataService) { }
/**
* Get all available streams
@ -38,15 +38,10 @@ export class StreamProviderService implements StreamProvider {
showTodayInRibbon: true,
addCommand: true,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
disabled: false
};
}
/**
* Get the currently active stream
* @returns The active stream or undefined if none is active
*/
getActiveStream(): Stream | undefined {
return this.streamDataService.getActiveStream();
}
}

View file

@ -5,9 +5,9 @@ import { DateStateManager } from '../../shared/DateStateManager';
import { centralizedLogger } from '../../shared/CentralizedLogger';
interface PluginInterface {
settings: {
settings?: {
primaryStreamId?: string | null;
};
setActiveStream(streamId: string, force?: boolean, suppressEvent?: boolean): Promise<void>;
}
/**
@ -57,6 +57,8 @@ export class StreamSelector extends Component {
this.dropdown.empty();
const primaryStreamId = this.plugin?.settings?.primaryStreamId ?? null;
// Filter out disabled streams
const enabledStreams = this.streams.filter(stream => !stream.disabled);
@ -73,6 +75,14 @@ export class StreamSelector extends Component {
const streamName = streamItem.createDiv('streams-bar-stream-item-name');
streamName.setText(stream.name);
// Subtle indicator for the primary stream (configured in settings)
if (primaryStreamId && stream.id === primaryStreamId) {
streamItem.addClass('streams-bar-stream-item-primary');
const primaryIndicator = streamItem.createDiv('streams-bar-stream-item-primary-indicator');
primaryIndicator.setAttribute('title', 'Primary stream');
primaryIndicator.setAttribute('aria-label', 'Primary stream');
}
// Add encryption icon if stream is encrypted
if (stream.encryptThisStream) {
const encryptionIcon = streamItem.createDiv('streams-bar-stream-item-encryption');

View file

@ -18,10 +18,10 @@ import { ViewContainerService } from './ViewContainerService';
import { ComponentEventSubscriptionManager } from './ComponentEventSubscriptionManager';
import { ComponentStateManager } from './ComponentStateManager';
import { ComponentUIBuilder, ComponentCallbacks } from './ComponentUIBuilder';
import { getStreamBaseFolder } from '../file-operations/streamUtils';
interface PluginInterface {
settings: {
barStyle?: 'default' | 'modern';
};
saveSettings(): void;
setActiveStream(streamId: string, force?: boolean, suppressEvent?: boolean): Promise<void>;
@ -64,9 +64,7 @@ export class StreamsBarComponent extends Component {
private streamContextService: StreamContextService;
public get activeStreamId(): string {
return this.selectedStream?.id || '';
}
public updateReuseCurrentTab(reuseCurrentTab: boolean): void {
this.reuseCurrentTab = reuseCurrentTab;
@ -101,12 +99,11 @@ export class StreamsBarComponent extends Component {
this.eventRegistry = new EventHandlerRegistry();
this.viewContainerService = new ViewContainerService();
this.eventSubscriptionManager = new ComponentEventSubscriptionManager(this.dateStateManager);
this.stateManager = new ComponentStateManager(plugin, streams, stream, this.dateNavigationService);
this.stateManager = new ComponentStateManager(streams, stream, this.dateNavigationService);
this.currentMonthView = new Date();
this.component = document.createElement('div');
this.component.addClass('streams-bar-component');
this.stateManager.applyBarStyle(this.component);
this.initializeDateState(leaf);
this.eventSubscriptionManager.subscribeToDateChanges((state) => {
@ -117,15 +114,9 @@ export class StreamsBarComponent extends Component {
this.handleSettingsChange(settings);
});
const contentContainer = this.viewContainerService.findContentContainer(leaf);
if (!contentContainer) {
centralizedLogger.error('Could not find content container');
return;
}
this.viewContainerService.removeExistingComponents(leaf, '.streams-bar-component');
if (!this.viewContainerService.attachComponent(this.component, leaf, contentContainer)) {
if (!this.viewContainerService.attachComponent(this.component, leaf)) {
return;
}
@ -190,6 +181,20 @@ export class StreamsBarComponent extends Component {
if (this.selectedStream?.id !== stream.id) {
this.updateActiveStream(stream);
}
// Always update the date from the filename so the bar shows the correct
// date even when navigating to a stream file from outside the plugin
if (file) {
const match = file.basename.match(/^\d{4}-\d{2}-\d{2}/);
if (match) {
const [year, month, day] = match[0].split('-').map(n => parseInt(n, 10));
const date = new Date(year, month - 1, day);
if (!isNaN(date.getTime())) {
this.dateStateManager.setCurrentDate(date);
}
}
}
this.component.show();
} else {
// If no stream found for file, hide the component
@ -229,10 +234,7 @@ export class StreamsBarComponent extends Component {
}
private handleFileModify(file: TFile) {
const streamPath = this.selectedStream.folder.split(/[/\\]/).filter(Boolean);
const filePath = file.path.split(/[/\\]/).filter(Boolean);
const isInStream = streamPath.every((part, index) => streamPath[index] === filePath[index]);
const isInStream = this.streamContextService.isFileInStream(file, this.selectedStream);
if (isInStream && this.calendarRenderer) {
this.calendarRenderer.updateGridContent();
@ -321,14 +323,7 @@ export class StreamsBarComponent extends Component {
let isInStream = true;
if (view instanceof MarkdownView && view.file) {
const streamPath = this.selectedStream.folder.split(/[/\\]/).filter(Boolean);
const filePath = view.file.path.split(/[/\\]/).filter(Boolean);
if (filePath.length >= streamPath.length) {
isInStream = streamPath.every((part, index) => streamPath[index] === filePath[index]);
} else {
isInStream = false;
}
isInStream = this.streamContextService.isFileInStream(view.file, this.selectedStream);
}
if (!isInStream) {
@ -544,8 +539,6 @@ export class StreamsBarComponent extends Component {
}
private handleSettingsChange(settings: StreamsSettings): void {
this.stateManager.applyBarStyle(this.component);
if (settings.streams) {
// Update streams list in selector
this.updateStreamsList(settings.streams);
@ -555,7 +548,7 @@ export class StreamsBarComponent extends Component {
const updatedStream = settings.streams.find(s => s.id === this.selectedStream.id);
if (updatedStream) {
this.handleStreamSwitch(updatedStream);
this.updateActiveStream(updatedStream);
} else {
console.warn(`[StreamsBarComponent] Current stream ${this.selectedStream.id} not found in new settings.`);
}
@ -563,8 +556,5 @@ export class StreamsBarComponent extends Component {
}
}
public refreshBarStyle(): void {
this.stateManager.applyBarStyle(this.component);
}
}

View file

@ -1,4 +1,4 @@
import { WorkspaceLeaf, MarkdownView, View } from 'obsidian';
import { WorkspaceLeaf, MarkdownView, View, Platform } from 'obsidian';
import { centralizedLogger } from '../../shared/CentralizedLogger';
import { CREATE_FILE_VIEW_TYPE } from '../file-operations/CreateFileView';
import { INSTALL_MELD_VIEW_TYPE } from '../file-operations/InstallMeldView';
@ -14,57 +14,6 @@ interface ViewWithContentEl extends View {
* Extracted from StreamsBarComponent to follow Single Responsibility Principle
*/
export class ViewContainerService {
/**
* Find the content container for a given leaf based on its view type
* @param leaf The workspace leaf to find the container for
* @returns The content container element, or null if not found
*/
findContentContainer(leaf: WorkspaceLeaf): HTMLElement | null {
const viewType = leaf.view.getViewType();
let contentContainer: HTMLElement | null = null;
if (viewType === 'markdown') {
const markdownView = leaf.view as MarkdownView;
contentContainer = markdownView.contentEl;
} else if (viewType === CREATE_FILE_VIEW_TYPE ||
viewType === INSTALL_MELD_VIEW_TYPE ||
viewType === CREATE_FILE_VIEW_ENCRYPTED_TYPE) {
const view = leaf.view as unknown as ViewWithContentEl;
if (!view) {
centralizedLogger.error(`View is null for viewType: ${viewType}`);
return null;
}
contentContainer = view.contentEl;
} else if (viewType === 'empty') {
// For empty views, try to find the view-content element
const viewContent = leaf.view.containerEl.querySelector('.view-content');
if (viewContent) {
contentContainer = viewContent as HTMLElement;
} else {
centralizedLogger.error('Could not find view-content for empty view');
return null;
}
} else if (viewType === 'file-explorer') {
// For file explorer, add to the main content area
const mainContent = leaf.view.containerEl.querySelector('.nav-files-container') ||
leaf.view.containerEl.querySelector('.nav-files') ||
leaf.view.containerEl;
contentContainer = mainContent as HTMLElement;
} else {
const view = leaf.view as unknown as ViewWithContentEl;
if (!view) {
centralizedLogger.error('View is null');
return null;
}
contentContainer = view.contentEl;
}
return contentContainer;
}
/**
* Check if a leaf is in the main editor area (not a sidebar)
* @param leaf The workspace leaf to check
@ -86,18 +35,19 @@ export class ViewContainerService {
existingComponents.forEach(component => {
component.remove();
});
leafContainer.removeClass('streams-leaf-active');
}
/**
* Attach a component to the DOM in the appropriate location
* @param component The component element to attach
* @param leaf The workspace leaf to attach to
* @param contentContainer The content container element
* @returns True if attachment was successful, false otherwise
*/
attachComponent(component: HTMLElement, leaf: WorkspaceLeaf, contentContainer: HTMLElement): boolean {
// Add class to content container
contentContainer.addClass('streams-markdown-view-content');
attachComponent(component: HTMLElement, leaf: WorkspaceLeaf): boolean {
// Add state class to the root leaf container to reliably target layout CSS
leaf.view.containerEl.addClass('streams-leaf-active');
// Only add the calendar component if we're in the main editor area
if (!this.isMainEditorLeaf(leaf)) {
@ -115,9 +65,10 @@ export class ViewContainerService {
// Find the view-header within this specific leaf
const viewHeader = leafContainer.querySelector('.view-header');
if (viewHeader && viewHeader.parentElement) {
// Insert after the view-header for this specific leaf
viewHeader.parentElement.insertBefore(component, viewHeader.nextSibling);
if (viewHeader) {
// Append inside the view-header for all platforms (mobile, tablet, desktop)
// to ensure consistent placement across the app
viewHeader.appendChild(component);
} else {
// Fallback: attach to the leaf container itself
leafContainer.insertBefore(component, leafContainer.firstChild);

View file

@ -20,6 +20,7 @@ describe('FilePathProviderService', () => {
showTodayInRibbon: true,
addCommand: true,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
disabled: false
};

View file

@ -148,10 +148,10 @@ describe('ServiceCoordinator', () => {
it('should update existing components when settings change', () => {
const newSettings: StreamsSettings = {
streams: [],
primaryStreamId: null,
showStreamsBarComponent: true,
reuseCurrentTab: false,
debugLoggingEnabled: false,
barStyle: 'default'
debugLoggingEnabled: false
};
serviceCoordinator.onSettingsChanged(newSettings);

View file

@ -10,7 +10,7 @@ describe('StreamProviderService', () => {
let streamProviderService: StreamProviderService;
let mockStreams: Stream[];
let mockDefaultStream: Stream;
let mockActiveStream: Stream;
beforeEach(() => {
// Create mock streams
@ -23,6 +23,7 @@ describe('StreamProviderService', () => {
showTodayInRibbon: true,
addCommand: true,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
disabled: false
},
{
@ -33,18 +34,19 @@ describe('StreamProviderService', () => {
showTodayInRibbon: false,
addCommand: false,
encryptThisStream: true,
dateFormat: 'YYYY-MM-DD',
disabled: false
}
];
mockDefaultStream = mockStreams[0];
mockActiveStream = mockStreams[1];
// Mock the StreamDataService
streamDataService = new StreamDataService(jest.fn()) as jest.Mocked<StreamDataService>;
streamDataService.getStreams.mockReturnValue(mockStreams);
streamDataService.getDefaultStream.mockReturnValue(mockDefaultStream);
streamDataService.getActiveStream.mockReturnValue(mockActiveStream);
// Create the service under test
streamProviderService = new StreamProviderService(streamDataService);
@ -93,6 +95,7 @@ describe('StreamProviderService', () => {
showTodayInRibbon: true,
addCommand: true,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
disabled: false
});
expect(streamDataService.getDefaultStream).toHaveBeenCalledTimes(1);
@ -111,36 +114,12 @@ describe('StreamProviderService', () => {
showTodayInRibbon: true,
addCommand: true,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
disabled: false
});
expect(streamDataService.getDefaultStream).toHaveBeenCalledTimes(1);
});
});
describe('getActiveStream', () => {
it('should return active stream from StreamDataService', () => {
const result = streamProviderService.getActiveStream();
expect(result).toBe(mockActiveStream);
expect(streamDataService.getActiveStream).toHaveBeenCalledTimes(1);
});
it('should return undefined when no active stream', () => {
streamDataService.getActiveStream.mockReturnValue(undefined);
const result = streamProviderService.getActiveStream();
expect(result).toBeUndefined();
expect(streamDataService.getActiveStream).toHaveBeenCalledTimes(1);
});
it('should return null when StreamDataService returns null', () => {
streamDataService.getActiveStream.mockReturnValue(null as any);
const result = streamProviderService.getActiveStream();
expect(result).toBeNull();
expect(streamDataService.getActiveStream).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -6,11 +6,11 @@ import { Stream } from '../../../shared/types';
// Mock Obsidian
jest.mock('obsidian', () => ({
Component: class MockComponent {},
ItemView: class MockItemView {},
WorkspaceLeaf: class MockWorkspaceLeaf {},
PluginSettingTab: class MockPluginSettingTab {},
Modal: class MockModal {},
Component: class MockComponent { },
ItemView: class MockItemView { },
WorkspaceLeaf: class MockWorkspaceLeaf { },
PluginSettingTab: class MockPluginSettingTab { },
Modal: class MockModal { },
App: class MockApp {
workspace = {
getLeavesOfType: jest.fn(),
@ -47,14 +47,14 @@ describe('Calendar Navigation Integration Tests', () => {
showTodayInRibbon: true,
addCommand: true,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
disabled: false
}
],
activeStreamId: 'test-stream',
showStreamsBarComponent: true,
reuseCurrentTab: false,
debugLoggingEnabled: false,
barStyle: 'default' as const
debugLoggingEnabled: false
}
};
@ -103,7 +103,7 @@ describe('Calendar Navigation Integration Tests', () => {
// Test StreamProvider interface methods
expect(typeof streamProviderService.getStreams).toBe('function');
expect(typeof streamProviderService.getDefaultStream).toBe('function');
expect(typeof streamProviderService.getActiveStream).toBe('function');
// Test FilePathProvider interface methods
expect(typeof filePathProviderService.getDefaultFilePath).toBe('function');
@ -111,13 +111,13 @@ describe('Calendar Navigation Integration Tests', () => {
// Verify method calls work
const streams = streamProviderService.getStreams();
const defaultStream = streamProviderService.getDefaultStream();
const activeStream = streamProviderService.getActiveStream();
const filePath = filePathProviderService.getDefaultFilePath(defaultStream);
expect(Array.isArray(streams)).toBe(true);
expect(defaultStream).toBeDefined();
expect(typeof filePath).toBe('string');
expect(activeStream).toBeDefined();
});
it('should handle service lifecycle correctly', async () => {
@ -136,7 +136,7 @@ describe('Calendar Navigation Integration Tests', () => {
const newSettings = {
...mockSettingsManager.settings,
activeStreamId: 'new-stream'
};
// Mock component lifecycle manager methods

View file

@ -7,6 +7,7 @@ import { EmptyStateObserver } from './EmptyStateObserver';
import { StreamManager } from '../../shared/interfaces';
import { MeldDetectionService } from '../../slices/meld-integration';
import { eventBus } from '../../shared/EventBus';
import { resolveStreamFilePath } from './streamUtils';
// Interface for accessing app.plugins
interface AppWithPlugins extends App {
@ -109,12 +110,9 @@ export class CreateFileView extends ItemView {
// Otherwise, calculate from current date and stream.
if (state.filePath) {
this.filePath = state.filePath;
} else {
// Recalculate file path based on current stream and date (which is now updated)
const currentDate = this.dateStateManager.getState().currentDate;
const fileName = `${this.formatDateToYYYYMMDD(currentDate)}.md`;
const streamFolder = this.stream.folder.replace(/\/$/, '');
this.filePath = `${streamFolder}/${fileName}`;
this.filePath = resolveStreamFilePath(this.stream, currentDate);
}
@ -124,6 +122,7 @@ export class CreateFileView extends ItemView {
this.contentEl.empty();
this.contentEl.addClass('streams-create-file-container');
this.containerEl.addClass('streams-create-file-leaf');
this.createFileViewContent(this.contentEl);
}
@ -137,26 +136,19 @@ export class CreateFileView extends ItemView {
private handleDateChange(state: DateState): void {
// Update the file path based on the new date
const fileName = `${this.formatDateToYYYYMMDD(state.currentDate)}.md`;
const streamFolder = this.stream.folder.replace(/\/$/, '');
this.filePath = `${streamFolder}/${fileName}`;
this.filePath = resolveStreamFilePath(this.stream, state.currentDate);
// Refresh the view content
if (this.contentEl) {
this.contentEl.empty();
this.contentEl.addClass('streams-create-file-container');
this.containerEl.addClass('streams-create-file-leaf');
this.createFileViewContent(this.contentEl);
}
}
private formatDateToYYYYMMDD(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
async onOpen(): Promise<void> {
// Set this as the active stream in the main plugin - REMOVED logic
@ -173,6 +165,7 @@ export class CreateFileView extends ItemView {
// Prepare our content element
this.contentEl.empty();
this.contentEl.addClass('streams-create-file-container');
this.containerEl.addClass('streams-create-file-leaf');
// Content element styling is handled by CSS class

View file

@ -6,6 +6,7 @@ import { configurationService } from '../../shared/ConfigurationService';
import { getPluginById, getCommands, executeCommandById } from '../../shared/obsidian-types';
import { ServiceContainer } from '../../shared/interfaces';
import { MeldDetectionService } from '../../slices/meld-integration';
import { resolveStreamFilePath } from './streamUtils';
// Interface for the streams plugin
interface StreamsPlugin {
@ -105,6 +106,7 @@ export class CreateFileViewEncrypted extends ItemView {
if (this.contentEl) {
this.contentEl.empty();
this.contentEl.addClass('streams-create-file-encrypted-container');
this.containerEl.addClass('streams-create-file-encrypted-leaf');
this.createFileViewEncryptedContent(this.contentEl);
}
}
@ -116,24 +118,18 @@ export class CreateFileViewEncrypted extends ItemView {
private handleDateChange(state: DateState): void {
// Update the file path based on the new date
const fileName = `${this.formatDateToYYYYMMDD(state.currentDate)}.md`;
const streamFolder = this.stream.folder.replace(/\/$/, '');
this.filePath = `${streamFolder}/${fileName}`;
this.filePath = resolveStreamFilePath(this.stream, state.currentDate);
// Refresh the view content
if (this.contentEl) {
this.contentEl.empty();
this.contentEl.addClass('streams-create-file-encrypted-container');
this.containerEl.addClass('streams-create-file-encrypted-leaf');
this.createFileViewEncryptedContent(this.contentEl);
}
}
private formatDateToYYYYMMDD(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
async onOpen(): Promise<void> {
// Set this as the active stream in the main plugin
@ -151,6 +147,7 @@ export class CreateFileViewEncrypted extends ItemView {
// Prepare our content element
this.contentEl.empty();
this.contentEl.addClass('streams-create-file-encrypted-container');
this.containerEl.addClass('streams-create-file-encrypted-leaf');
// Content element styling is handled by CSS class

View file

@ -4,7 +4,7 @@ import { CommandService, ViewService } from '../../shared/interfaces';
import { Stream } from '../../shared/types';
import { OpenStreamDateCommand } from './OpenStreamDateCommand';
import { OpenTodayStreamCommand } from './OpenTodayStreamCommand';
import { OpenTodayCurrentStreamCommand } from './OpenTodayCurrentStreamCommand';
import { OpenTodayPrimaryStreamCommand } from './OpenTodayPrimaryStreamCommand';
import { FileCreationInterface, NormalFileStrategy, MeldEncryptedFileStrategy } from './file-creation-strategies';
export class FileOperationsService extends PluginAwareSliceService implements CommandService, ViewService {
@ -43,13 +43,13 @@ export class FileOperationsService extends PluginAwareSliceService implements Co
plugin.addCommand({
id: 'open-today-current-stream',
name: 'Open today for current stream',
name: 'Go to primary stream',
callback: () => {
const command = new OpenTodayCurrentStreamCommand(
plugin.app,
plugin.settings?.streams || [],
plugin.settings?.reuseCurrentTab || false,
plugin
const command = new OpenTodayPrimaryStreamCommand(
plugin.app,
plugin.settings?.streams || [],
plugin,
plugin.settings?.reuseCurrentTab || false
);
command.execute();
}

View file

@ -3,21 +3,22 @@ import { Stream } from '../../shared/types';
import { centralizedLogger } from '../../shared/CentralizedLogger';
import { DateStateManager, DateState } from '../../shared/DateStateManager';
import { ViewWithEmptyStateObserver, getSetting } from '../../shared/obsidian-types';
import { resolveStreamFilePath } from './streamUtils';
export const INSTALL_MELD_VIEW_TYPE = 'streams-install-meld-view';
export class InstallMeldView extends ItemView {
navigation = true; // Enable navigation history integration
private filePath: string;
private stream: Stream;
private date: Date;
private dateStateManager: DateStateManager;
private unsubscribeDateChanged: (() => void) | null = null;
constructor(
leaf: WorkspaceLeaf,
app: App,
leaf: WorkspaceLeaf,
app: App,
filePath: string,
stream: Stream,
date: Date
@ -58,16 +59,16 @@ export class InstallMeldView extends ItemView {
if (!this || !this.contentEl || !this.leaf || this.contentEl === null || this.leaf === null) {
return;
}
// Additional safety check - ensure the view is still attached to the DOM
if (!document.contains(this.contentEl)) {
return;
}
if (state) {
this.filePath = state.filePath || this.filePath;
this.stream = state.stream || this.stream;
// Handle date parameter
if (state.date) {
const newDate = typeof state.date === 'string' ? new Date(state.date) : state.date;
@ -75,7 +76,7 @@ export class InstallMeldView extends ItemView {
this.date = newDate;
}
}
// Refresh the view with new state
if (this.contentEl) {
this.contentEl.empty();
@ -93,19 +94,19 @@ export class InstallMeldView extends ItemView {
this.unsubscribeDateChanged = this.dateStateManager.onDateChanged((state) => {
this.handleDateChange(state);
});
// Trigger streams bar component to be added to this view
this.triggerCalendarComponent();
// Prepare our content element
this.contentEl.empty();
this.contentEl.addClass('streams-install-meld-container');
// Content element styling is handled by CSS class
// Hide any empty-state elements that might still be present
this.hideEmptyStates();
// Create our install meld view content
this.createInstallMeldViewContent(this.contentEl);
}
@ -116,12 +117,12 @@ export class InstallMeldView extends ItemView {
this.unsubscribeDateChanged();
this.unsubscribeDateChanged = null;
}
// Clear content and mark as invalid
if (this.contentEl) {
this.contentEl.empty();
}
// Mark the view as invalid to prevent setState calls
// Note: TypeScript doesn't allow null assignment to non-nullable properties
// This is intentional cleanup - the view is being destroyed
@ -130,41 +131,41 @@ export class InstallMeldView extends ItemView {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this as any).leaf = null;
}
private createInstallMeldViewContent(container: HTMLElement): void {
// Create the content box
const contentBox = container.createDiv('streams-install-meld-content');
// Add icon
const iconContainer = contentBox.createDiv('streams-install-meld-icon');
setIcon(iconContainer, 'lock');
// Stream info display
const streamContainer = contentBox.createDiv('streams-install-meld-stream-container');
const streamIcon = streamContainer.createSpan('streams-install-meld-stream-icon');
setIcon(streamIcon, this.stream.icon || 'book');
const streamName = streamContainer.createSpan('streams-install-meld-stream');
streamName.setText(this.stream.name);
// Date display
const dateEl = contentBox.createDiv('streams-install-meld-date');
const formattedDate = this.formatDate(this.date);
dateEl.setText(formattedDate);
// Create button
const buttonContainer = contentBox.createDiv('streams-install-meld-button-container');
const createButton = buttonContainer.createEl('button', {
cls: 'mod-cta streams-install-meld-button',
text: 'Install Meld Encrypt Plugin'
});
createButton.addEventListener('click', () => {
this.openMeldPluginPage();
});
}
private triggerCalendarComponent(): void {
// Trigger the streams bar component to be added to this view
try {
@ -175,40 +176,40 @@ export class InstallMeldView extends ItemView {
// Calendar component trigger failed - not critical
}
}
private hideEmptyStates(): void {
// Check if leaf and view are still valid
if (!this.leaf || !this.leaf.view || !this.leaf.view.containerEl) {
return;
}
const hideEmptyStates = () => {
// Double-check that leaf and view are still valid
if (!this.leaf || !this.leaf.view || !this.leaf.view.containerEl) {
return;
}
const emptyStates = this.leaf.view.containerEl.querySelectorAll('.empty-state, .empty-state-container');
emptyStates.forEach(el => {
const htmlEl = el as HTMLElement;
htmlEl.addClass('streams-empty-state-hidden');
});
};
// Hide them immediately
hideEmptyStates();
// Set up a MutationObserver to hide them if they get recreated
const observer = new MutationObserver(() => {
hideEmptyStates();
});
observer.observe(this.leaf.view.containerEl, {
childList: true,
subtree: true,
attributes: false
});
// Store observer for cleanup
// Using ViewWithEmptyStateObserver interface for proper typing
const view = this.leaf.view as ViewWithEmptyStateObserver;
@ -216,7 +217,7 @@ export class InstallMeldView extends ItemView {
view.emptyStateObserver = observer;
}
}
private formatTitleDate(date: Date): string {
return date.toLocaleDateString('en-US', {
month: 'short',
@ -224,13 +225,11 @@ export class InstallMeldView extends ItemView {
year: 'numeric'
});
}
private handleDateChange(state: DateState): void {
// Update the file path based on the new date
const fileName = `${this.formatDateToYYYYMMDD(state.currentDate)}.mdenc`;
const folderPath = this.filePath.substring(0, this.filePath.lastIndexOf('/'));
this.filePath = folderPath ? `${folderPath}/${fileName}` : fileName;
this.filePath = resolveStreamFilePath(this.stream, state.currentDate, 'mdenc');
// Refresh the view content
if (this.contentEl) {
this.contentEl.empty();
@ -239,27 +238,22 @@ export class InstallMeldView extends ItemView {
}
}
private formatDateToYYYYMMDD(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
private formatDate(date: Date): string {
try {
return date.toLocaleDateString('en-US', {
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch (error) {
centralizedLogger.error(`Error formatting date: ${error}`);
return "Invalid Date";
}
}
private openMeldPluginPage(): void {
// Open the Meld plugin page in the community plugins
try {
@ -275,6 +269,6 @@ export class InstallMeldView extends ItemView {
new Notice('Please manually search for "Meld Encrypt" in Community Plugins');
}
}
}

View file

@ -6,6 +6,59 @@ import { centralizedLogger } from '../../shared/CentralizedLogger';
* Extracted from streamUtils.ts to eliminate code duplication and follow SRP
*/
export class LeafSelectionService {
/**
* Checks whether a workspace leaf is pinned.
* Obsidian exposes this via the internal `pinned` property on the leaf.
*/
private static isLeafPinned(leaf: WorkspaceLeaf): boolean {
return (leaf as any).pinned === true;
}
/**
* 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. 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 in the main area, or a new tab if none found
*/
private static findNextUnpinnedLeaf(
app: App,
viewTypeFilter?: (viewType: string) => boolean
): WorkspaceLeaf | null {
// 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);
}
}
});
if (candidates.length > 0) {
return candidates[0];
}
// No unpinned leaf found in main area — create a new tab as last resort
try {
return app.workspace.getLeaf('tab');
} catch (error) {
centralizedLogger.error('Failed to create new leaf:', error);
return null;
}
}
/**
* Selects the appropriate leaf based on reuseCurrentTab setting
* @param app - Obsidian app instance
@ -30,16 +83,12 @@ export class LeafSelectionService {
*/
private static reuseCurrentLeaf(app: App): WorkspaceLeaf | null {
const activeLeaf = app.workspace.activeLeaf;
if (activeLeaf) {
if (activeLeaf && !this.isLeafPinned(activeLeaf) && this.isInMainEditorArea(app, activeLeaf)) {
return activeLeaf;
}
try {
return app.workspace.getLeaf('tab');
} catch (error) {
centralizedLogger.error('Failed to create new leaf:', error);
return null;
}
// Active leaf is pinned or in a side pane — find the next unpinned leaf instead
return this.findNextUnpinnedLeaf(app);
}
/**
@ -51,19 +100,15 @@ export class LeafSelectionService {
): WorkspaceLeaf | null {
const activeLeaf = app.workspace.activeLeaf;
if (activeLeaf) {
if (activeLeaf && !this.isLeafPinned(activeLeaf) && this.isInMainEditorArea(app, activeLeaf)) {
const viewType = activeLeaf.view.getViewType();
if (!viewTypeFilter || viewTypeFilter(viewType)) {
return activeLeaf;
}
}
try {
return app.workspace.getLeaf('tab');
} catch (error) {
centralizedLogger.error('Failed to create new leaf:', error);
return null;
}
// Active leaf is pinned, filtered out, or in a side pane — find the next unpinned leaf
return this.findNextUnpinnedLeaf(app, viewTypeFilter);
}
/**
@ -78,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;
@ -101,18 +147,13 @@ export class LeafSelectionService {
// If not found and reuseCurrentTab is enabled, try to reuse current leaf
if (reuseCurrentTab) {
const activeLeaf = app.workspace.activeLeaf;
if (activeLeaf) {
if (activeLeaf && !this.isLeafPinned(activeLeaf) && this.isInMainEditorArea(app, activeLeaf)) {
return activeLeaf;
}
}
// Create a new leaf
try {
return app.workspace.getLeaf('tab');
} catch (error) {
centralizedLogger.error('Failed to create new leaf:', error);
return null;
}
// Find the next unpinned leaf in the main area, or create a new tab as last resort
return this.findNextUnpinnedLeaf(app);
}
}

View file

@ -1,64 +0,0 @@
import { App, Notice, WorkspaceLeaf, MarkdownView } from 'obsidian';
import { Stream } from '../../shared/types';
import { openStreamDate } from './streamUtils';
import { Logger } from '../debug-logging/Logger';
import { Command, StreamManager } from '../../shared/interfaces';
import { DateStateManager } from '../../shared/DateStateManager';
import { StreamContextService } from '../../shared/StreamContextService';
const log = new Logger();
export class OpenTodayCurrentStreamCommand implements Command {
constructor(
private app: App,
private streams: Stream[],
private reuseCurrentTab: boolean = false,
private plugin?: StreamManager,
private targetStream?: Stream,
private targetLeaf?: WorkspaceLeaf,
private dateStateManager?: DateStateManager
) { }
async execute(): Promise<void> {
log.debug('Executing OpenTodayCurrentStreamCommand');
// Check if there are any streams configured
if (this.streams.length === 0) {
log.debug('No streams configured');
new Notice('No streams configured. Please add streams in the plugin settings first.');
return;
}
// Get the current stream from the targetStream or dynamic resolution
const currentStream = this.targetStream || this.findCurrentStream();
if (!currentStream) {
log.debug('No current stream found, cannot open today note');
new Notice('No active stream context found. Please open a file belonging to a stream first.');
return;
}
log.debug(`Opening today's note for current stream: ${currentStream.name}`);
await openStreamDate(this.app, currentStream, new Date(), this.reuseCurrentTab, this.targetLeaf, this.dateStateManager);
}
private findCurrentStream(): Stream | null {
const streamContextService = new StreamContextService();
// 1. Try target leaf if provided
if (this.targetLeaf && this.targetLeaf.view instanceof MarkdownView && this.targetLeaf.view.file) {
const stream = streamContextService.getStreamForFile(this.targetLeaf.view.file, this.streams);
if (stream) return stream;
}
// 2. Try active file
const activeFile = this.app.workspace.getActiveFile();
if (activeFile) {
const stream = streamContextService.getStreamForFile(activeFile, this.streams);
if (stream) return stream;
}
log.debug('No stream context found for current context');
return null;
}
}

View file

@ -0,0 +1,64 @@
import { App, Notice, WorkspaceLeaf } from 'obsidian';
import { Stream } from '../../shared/types';
import { openStreamDate } from './streamUtils';
import { Logger } from '../debug-logging/Logger';
import { Command, SettingsManager } from '../../shared/interfaces';
import { DateStateManager } from '../../shared/DateStateManager';
const log = new Logger();
/**
* Opens today's note for the single configured "primary" stream.
*
* The primary stream is selected in settings (stored as settings.primaryStreamId).
*/
export class OpenTodayPrimaryStreamCommand implements Command {
constructor(
private app: App,
private streams: Stream[],
private settingsManager: SettingsManager,
private reuseCurrentTab: boolean = false,
private targetLeaf?: WorkspaceLeaf,
private dateStateManager?: DateStateManager
) { }
async execute(): Promise<void> {
log.debug('Executing OpenTodayPrimaryStreamCommand');
if (this.streams.length === 0) {
new Notice('No streams configured. Please add streams in the plugin settings first.');
return;
}
const primaryStreamId = this.settingsManager.settings.primaryStreamId;
if (!primaryStreamId) {
new Notice('No primary stream is set. Select one in the Streams plugin settings.');
return;
}
const primaryStream = this.streams.find(s => s.id === primaryStreamId) ?? null;
if (!primaryStream) {
// Settings references a stream that no longer exists.
this.settingsManager.settings.primaryStreamId = null;
await this.settingsManager.saveSettings();
new Notice('Primary stream is missing. Please select a primary stream in settings.');
return;
}
if (primaryStream.disabled) {
new Notice(`Primary stream "${primaryStream.name}" is disabled. Enable it or choose a different primary stream.`);
return;
}
log.debug(`Opening today's note for primary stream: ${primaryStream.name}`);
await openStreamDate(
this.app,
primaryStream,
new Date(),
this.reuseCurrentTab,
this.targetLeaf,
this.dateStateManager
);
}
}

View file

@ -1,4 +1,4 @@
import { App } from 'obsidian';
import { App, WorkspaceLeaf } from 'obsidian';
import { Stream } from '../../shared/types';
import { openStreamDate } from './streamUtils';
import { Logger } from '../debug-logging/Logger';
@ -11,12 +11,13 @@ export class OpenTodayStreamCommand implements Command {
constructor(
private app: App,
private stream: Stream,
private reuseCurrentTab: boolean = false
private reuseCurrentTab: boolean = false,
private targetLeaf?: WorkspaceLeaf
) {}
async execute(): Promise<void> {
log.debug(`Opening today's note for stream: ${this.stream.name}`);
log.debug(`Reuse current tab: ${this.reuseCurrentTab}`);
await openStreamDate(this.app, this.stream, new Date(), this.reuseCurrentTab);
await openStreamDate(this.app, this.stream, new Date(), this.reuseCurrentTab, this.targetLeaf);
}
}

View file

@ -27,11 +27,12 @@ describe('View Selection Logic for Meld Integration', () => {
const stream: Stream = {
id: 'test-stream',
name: 'Test Stream',
folder: 'test',
icon: 'file-text',
showTodayInRibbon: false,
addCommand: false,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
disabled: false,
};
@ -43,11 +44,12 @@ describe('View Selection Logic for Meld Integration', () => {
const stream: Stream = {
id: 'test-stream',
name: 'Test Stream',
folder: 'test',
icon: 'file-text',
showTodayInRibbon: false,
addCommand: false,
encryptThisStream: true,
dateFormat: 'YYYY-MM-DD',
disabled: false,
};
@ -61,11 +63,12 @@ describe('View Selection Logic for Meld Integration', () => {
const stream: Stream = {
id: 'test-stream',
name: 'Test Stream',
folder: 'test',
icon: 'file-text',
showTodayInRibbon: false,
addCommand: false,
encryptThisStream: true,
dateFormat: 'YYYY-MM-DD',
disabled: false,
};
@ -77,11 +80,12 @@ describe('View Selection Logic for Meld Integration', () => {
const stream: Stream = {
id: 'test-stream',
name: 'Test Stream',
folder: 'test',
icon: 'file-text',
showTodayInRibbon: false,
addCommand: false,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
disabled: false,
};

View file

@ -3,7 +3,7 @@ export { CreateFileView, CREATE_FILE_VIEW_TYPE } from './CreateFileView';
export { CreateFileViewEncrypted, CREATE_FILE_VIEW_ENCRYPTED_TYPE } from './CreateFileViewEncrypted';
export { InstallMeldView, INSTALL_MELD_VIEW_TYPE } from './InstallMeldView';
export { OpenStreamDateCommand } from './OpenStreamDateCommand';
export { OpenTodayCurrentStreamCommand } from './OpenTodayCurrentStreamCommand';
export { OpenTodayPrimaryStreamCommand } from './OpenTodayPrimaryStreamCommand';
export { OpenTodayStreamCommand } from './OpenTodayStreamCommand';
export * from './streamUtils';
export { LeafSelectionService } from './LeafSelectionService';

View file

@ -98,6 +98,29 @@ interface AppWithViewRegistry extends App {
/**
* Recursively creates a folder path if it doesn't exist.
*/
export async function ensureFolderExists(app: App, folderPath: string): Promise<void> {
if (!folderPath) return;
const parts = folderPath.split(/[/\\]/).filter(Boolean);
let currentPath = '';
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const folderExists = app.vault.getAbstractFileByPath(currentPath);
if (!folderExists) {
try {
await app.vault.createFolder(currentPath);
} catch (error) {
// Folder might have been created concurrently
centralizedLogger.debug(`Folder might already exist ${currentPath}`, error);
}
}
}
}
/**
* Formats a date as YYYY-MM-DD for filenames
*/
@ -133,20 +156,20 @@ export function getFolderSuggestions(app: App): string[] {
export async function createDailyNote(app: App, folder: string): Promise<TFile | null> {
const date = new Date();
const fileName = `${formatDateToYYYYMMDD(date)}.md`;
// Use the flexible resolver but for a generic/default stream context
const fakeStream: Stream = {
id: 'tmp', name: 'tmp', icon: 'book', showTodayInRibbon: false, addCommand: false,
encryptThisStream: false, disabled: false,
dateFormat: `${folder}/YYYY-MM-DD`
};
const folderPath = folder
.split(/[/\\]/)
.filter(Boolean)
.join('/');
const filePath = folderPath ? `${folderPath}/${fileName}` : fileName;
const filePath = resolveStreamFilePath(fakeStream, date);
let file = app.vault.getAbstractFileByPath(filePath);
if (!file) {
if (folderPath && !app.vault.getAbstractFileByPath(folderPath)) {
await app.vault.createFolder(folderPath);
}
const folderPath = filePath.substring(0, filePath.lastIndexOf('/'));
await ensureFolderExists(app, folderPath);
const template = '';
file = await app.vault.create(filePath, template);
@ -155,6 +178,64 @@ export async function createDailyNote(app: App, folder: string): Promise<TFile |
return file instanceof TFile ? file : null;
}
/**
* Resolves a dynamic file path for a stream using the provided date.
* Supports `{TOKEN}` bracket syntax for mixing literal text and moment.js formats.
* Maintains backwards compatibility for `dateFormat` strings without brackets.
*/
export function resolveStreamFilePath(stream: Stream, date: Date | string, extension: string = 'md'): string {
const d = typeof date === 'string' ? new Date(date) : date;
const momentDate = (window as any).moment(d);
// Process File path template (File Name + Path)
let fullPath = stream.dateFormat || 'YYYY-MM-DD';
if (fullPath.includes('{')) {
// Evaluate {TOKENS}
fullPath = fullPath.replace(/\{([^}]+)\}/g, (_, token) => momentDate.format(token));
} else {
// Legacy mode: try to intelligently wrap recognized date tokens to prevent parsing literal folder names
// Only target standard Y/M/D/H/m/s/w components separated by non-word chars
fullPath = fullPath.replace(/\b(Y+|M+|D+|d+|H+|h+|m+|s+|w+|W+|A|a)\b/g, (match) => {
return momentDate.format(match);
});
}
if (!fullPath.endsWith(`.${extension}`)) {
fullPath += `.${extension}`;
}
// Normalize path for Obsidian API compatibility (remove leading slash if present)
if (fullPath.startsWith('/')) {
fullPath = fullPath.slice(1);
}
return fullPath;
}
/**
* Extracts the static top-level directory from a stream's file path template.
* Used to identify if an arbitrary file belongs to a stream.
*/
export function getStreamBaseFolder(stream: Stream): string {
const format = stream.dateFormat || '';
const braceIndex = format.indexOf('{');
let prefix = format;
if (braceIndex !== -1) {
prefix = format.substring(0, braceIndex);
}
// Find the last slash in the remaining literal prefix
const lastSlash = prefix.lastIndexOf('/');
if (lastSlash !== -1) {
return prefix.substring(0, lastSlash);
}
return '';
}
export async function openStreamDate(app: App, stream: Stream, date: Date = new Date(), reuseCurrentTab = false, targetLeaf?: WorkspaceLeaf, dateStateManager?: DateStateManager): Promise<void> {
// Opening stream date
@ -163,15 +244,10 @@ export async function openStreamDate(app: App, stream: Stream, date: Date = new
return;
}
const fileName = `${formatDateToYYYYMMDD(date)}.md`;
// Formatted date
const folderPath = stream.folder
.split(/[/\\]/)
.filter(Boolean)
.join('/');
// Always recalculate filePath from the stream object, which should be fresh
const filePath = folderPath ? `${folderPath}/${fileName}` : fileName;
const filePath = resolveStreamFilePath(stream, date);
const folderPath = filePath.substring(0, filePath.lastIndexOf('/'));
// Looking for file at path
let file = app.vault.getAbstractFileByPath(filePath);
@ -184,17 +260,7 @@ export async function openStreamDate(app: App, stream: Stream, date: Date = new
if (!file) {
// File not found, showing create file view
if (folderPath) {
try {
const folderExists = app.vault.getAbstractFileByPath(folderPath);
if (!folderExists) {
await app.vault.createFolder(folderPath);
// Created folder
}
} catch (error) {
// log.debug('Using existing folder:', folderPath);
}
}
await ensureFolderExists(app, folderPath);
// Use targetLeaf if provided, otherwise select appropriate leaf
const leaf = targetLeaf || LeafSelectionService.selectLeaf(

View file

@ -3,13 +3,15 @@ import { SettingsAwareSliceService } from '../../shared/BaseSlice';
import { Stream, StreamsSettings } from '../../shared/types';
import { eventBus, EVENTS } from '../../shared/EventBus';
import { OpenTodayStreamCommand } from '../file-operations/OpenTodayStreamCommand';
import { OpenTodayCurrentStreamCommand } from '../file-operations/OpenTodayCurrentStreamCommand';
import { OpenTodayPrimaryStreamCommand } from '../file-operations/OpenTodayPrimaryStreamCommand';
export class RibbonService extends SettingsAwareSliceService {
private ribbonIconsByStream: Map<string, { today?: HTMLElement }> = new Map();
private commandsByStreamId: Map<string, string> = new Map();
private instanceId = Math.random().toString(36).substring(7);
async initialize(): Promise<void> {
this.log(`[RibbonService-${this.instanceId}] Initializing...`);
if (this.initialized) return;
this.initializeAllRibbonIcons();
@ -27,14 +29,30 @@ export class RibbonService extends SettingsAwareSliceService {
}
private registerEventBusListeners(): void {
this.log(`[RibbonService-${this.instanceId}] Registering event bus listeners`);
// Listen for stream changes
eventBus.subscribe(EVENTS.STREAM_ADDED, (event) => this.onStreamAdded(event.data));
eventBus.subscribe(EVENTS.STREAM_UPDATED, (event) => this.onStreamUpdated(event.data));
eventBus.subscribe(EVENTS.STREAM_REMOVED, (event) => this.onStreamRemoved(event.data.streamId));
eventBus.subscribe(EVENTS.STREAM_ADDED, (event) => {
if (event.data && 'stream' in event.data) {
this.onStreamAdded((event.data as any).stream);
}
});
eventBus.subscribe(EVENTS.STREAM_UPDATED, (event) => {
if (event.data && 'stream' in event.data) {
this.onStreamUpdated((event.data as any).stream);
}
});
eventBus.subscribe(EVENTS.STREAM_REMOVED, (event) => {
if (event.data && 'streamId' in event.data) {
this.onStreamRemoved((event.data as any).streamId);
}
});
// eventBus.subscribe(EVENTS.ACTIVE_STREAM_CHANGED, () => this.updateAllRibbonIcons()); - REMOVED
// Listen for settings changes
eventBus.subscribe(EVENTS.SETTINGS_CHANGED, () => this.updateAllRibbonIcons());
eventBus.subscribe(EVENTS.SETTINGS_CHANGED, () => {
this.log('[RibbonService] Received SETTINGS_CHANGED event');
this.updateAllRibbonIcons();
});
}
private onStreamAdded(stream: Stream): void {
@ -65,22 +83,26 @@ export class RibbonService extends SettingsAwareSliceService {
});
}
public updateAllRibbonIcons(): void {
this.removeAllRibbonIcons();
this.initializeAllRibbonIcons();
}
private createAllStreamsIcon(): void {
// Open today for current stream button
// Go to primary stream button
this.getPlugin().addRibbonIcon(
'calendar',
'Streams: Open today for current stream',
() => {
const command = new OpenTodayCurrentStreamCommand(
'Streams: Go to primary stream',
(evt: MouseEvent) => {
// Ctrl/Cmd+click forces a new tab
const forceNewTab = evt.ctrlKey || evt.metaKey;
const targetLeaf = forceNewTab
? this.getPlugin().app.workspace.getLeaf('tab')
: undefined;
const command = new OpenTodayPrimaryStreamCommand(
this.getPlugin().app,
this.getStreams(),
this.getPlugin(),
this.getSettings().reuseCurrentTab,
this.getPlugin()
targetLeaf
);
command.execute();
}
@ -88,6 +110,13 @@ export class RibbonService extends SettingsAwareSliceService {
}
private createStreamIcons(stream: Stream): void {
this.log(`[RibbonService-${this.instanceId}] createStreamIcons called for ${stream?.name} (${stream?.id}). Show: ${stream?.showTodayInRibbon}, Disabled: ${stream?.disabled}`);
if (!stream || !stream.id) {
this.log(`[RibbonService-${this.instanceId}] Attempted to create icons for invalid stream`, stream);
return;
}
// Get or create entry for this stream
let streamIcons = this.ribbonIconsByStream.get(stream.id);
if (!streamIcons) {
@ -95,42 +124,210 @@ export class RibbonService extends SettingsAwareSliceService {
this.ribbonIconsByStream.set(stream.id, streamIcons);
}
// Only create the icon if it should be visible
if (stream.showTodayInRibbon && !streamIcons.today) {
this.log(`Creating Today icon for stream ${stream.id}`);
const shouldBeVisible = stream.showTodayInRibbon && !stream.disabled;
streamIcons.today = this.getPlugin().addRibbonIcon(
stream.icon,
`Open today for ${stream.name}`,
() => {
const command = new OpenTodayStreamCommand(
this.getPlugin().app,
stream,
this.getSettings().reuseCurrentTab
);
command.execute();
}
);
// Case 1: Should NOT be visible
if (!shouldBeVisible) {
if (streamIcons.today) {
const label = streamIcons.today.getAttribute('aria-label') || 'unknown';
this.log(`[RibbonService-${this.instanceId}] Toggling OFF: Removing icon for stream "${stream.name}" (ID: ${stream.id}). Button aria-label: "${label}"`);
streamIcons.today.remove();
streamIcons.today = undefined;
} else {
this.log(`[RibbonService-${this.instanceId}] Toggling OFF (No action): Icon for stream "${stream.name}" was already missing.`);
}
// Ensure no ghosts remain
this.enforceRibbonConsistency();
return;
}
// Case 2: Should be visible
// Check if existing icon is valid
if (streamIcons.today) {
if (streamIcons.today.isConnected) {
this.log(`[RibbonService-${this.instanceId}] Toggling ON (No action): Icon for stream "${stream.name}" is already present and connected.`);
// Already valid and in DOM. Do nothing.
return;
} else {
this.log(`[RibbonService-${this.instanceId}] Found disconnected reference for "${stream.name}". Clearing reference.`);
// Reference exists but not in DOM. Clear reference.
streamIcons.today = undefined;
}
}
// Clean up any ghosts before creating (but preserve tracked one, which we just checked isn't there)
// enforceRibbonConsistency is called AFTER creation to ensure we know what the 'tracked' one is.
this.log(`[RibbonService-${this.instanceId}] Toggling ON: Creating new ribbon icon for stream "${stream.name}" (ID: ${stream.id})`);
const iconEl = this.getPlugin().addRibbonIcon(
stream.icon,
`Open today for ${stream.name}`,
(evt: MouseEvent) => {
// Ctrl/Cmd+click forces a new tab
const forceNewTab = evt.ctrlKey || evt.metaKey;
const targetLeaf = forceNewTab
? this.getPlugin().app.workspace.getLeaf('tab')
: undefined;
const command = new OpenTodayStreamCommand(
this.getPlugin().app,
stream,
this.getSettings().reuseCurrentTab,
targetLeaf
);
command.execute();
}
);
// Tag the icon for robust identification and removal
iconEl.addClass('streams-ribbon-icon');
iconEl.setAttribute('data-stream-id', stream.id);
this.log(`[RibbonService-${this.instanceId}] Created icon element: ${iconEl.outerHTML}`);
streamIcons.today = iconEl;
// Final policing pass
this.enforceRibbonConsistency();
}
private removeStreamIcons(streamId: string): void {
this.log(`[RibbonService-${this.instanceId}] removeStreamIcons called for ${streamId}`);
if (!streamId) return;
// Try to find stream name for better ghost busting
let streamName = '';
const stream = this.getStreams().find(s => s.id === streamId);
if (stream) {
streamName = stream.name;
}
// 1. Remove tracked icon
const streamIcons = this.ribbonIconsByStream.get(streamId);
if (streamIcons) {
if (streamIcons.today) {
streamIcons.today.remove();
streamIcons.today = undefined;
}
this.ribbonIconsByStream.delete(streamId);
}
// 2. Remove any "ghost" icons from the DOM
this.enforceRibbonConsistency();
}
private enforceRibbonConsistency(): void {
this.log(`[RibbonService-${this.instanceId}] Enforcing ribbon consistency...`);
const streams = this.getStreams();
// Set of IDs that are ALLOWED to have an icon
const allowedStreamIds = new Set(
streams.filter(s => s.showTodayInRibbon && !s.disabled).map(s => s.id)
);
// Map Name -> ID for looking up untagged icons
const nameToId = new Map(streams.map(s => [s.name, s.id]));
// 1. Scan ALL icons that look like ours (tagged)
const taggedIcons = document.querySelectorAll('.streams-ribbon-icon');
taggedIcons.forEach(icon => {
const streamId = icon.getAttribute('data-stream-id');
if (streamId) {
// Is this stream allowed to be visible?
if (!allowedStreamIds.has(streamId)) {
this.log(`[RibbonService-${this.instanceId}] Policing: Removing hidden stream icon (ID: ${streamId})`);
icon.remove();
return;
}
// It IS allowed, but is it the "Blessed" instance?
// If we have multiple icons for the same valid stream, only the tracked one survives.
const tracked = this.ribbonIconsByStream.get(streamId)?.today;
if (tracked && tracked !== icon) {
this.log(`[RibbonService-${this.instanceId}] Policing: Removing duplicate/stale icon for valid stream (ID: ${streamId})`);
icon.remove();
} else if (!tracked) {
// It's allowed, but we aren't tracking it?
// This creates a dilemma. If we are just initializing, we might not have tracked it yet.
// But we called 'createStreamIcons' which sets tracking.
// So if it's untracked, it's a ghost.
this.log(`[RibbonService-${this.instanceId}] Policing: Removing untracked ghost icon for valid stream (ID: ${streamId})`);
icon.remove();
}
} else {
// Has class but no ID? Suspicious. Remove.
this.log(`[RibbonService-${this.instanceId}] Policing: Removing malformed icon`);
icon.remove();
}
});
// 2. Scan ALL ribbon actions for untagged "Islanders"
const allActions = document.querySelectorAll('.side-dock-ribbon-action');
allActions.forEach(icon => {
// Skip if it's already processed as tagged (optimization)
if (icon.classList.contains('streams-ribbon-icon')) return;
const label = icon.getAttribute('aria-label');
if (label && label.startsWith('Open today for ')) {
const streamName = label.substring('Open today for '.length);
const streamId = nameToId.get(streamName);
if (streamId) {
// We found a match in our streams!
// Since it is NOT tagged (we checked class), it is definitely a Ghost/Legacy icon.
// We REMOVE it always, because any valid icon MUST be tagged by now.
this.log(`[RibbonService-${this.instanceId}] Policing: Removing legacy untagged icon for "${streamName}"`);
icon.remove();
}
}
});
}
private removeAllRibbonIcons(): void {
for (const streamIcons of this.ribbonIconsByStream.values()) {
this.log(`[RibbonService-${this.instanceId}] removeAllRibbonIcons calling. Count: ${this.ribbonIconsByStream.size}`);
// 1. Clear tracked icons
for (const [id, streamIcons] of this.ribbonIconsByStream.entries()) {
if (streamIcons.today) {
streamIcons.today.remove();
}
}
this.ribbonIconsByStream.clear();
// 2. Sweep the DOM for any remaining icons with our class
const remainingIcons = document.querySelectorAll('.streams-ribbon-icon');
remainingIcons.forEach(icon => icon.remove());
}
public updateAllRibbonIcons(): void {
this.log(`[RibbonService-${this.instanceId}] updateAllRibbonIcons called (Diff Strategy)`);
// Log stack trace to identify caller
try {
throw new Error('Ribbon Update Trace');
} catch (e: any) {
this.log(`[RibbonService-${this.instanceId}] Triggered by: ${e.stack}`);
}
const streams = this.getStreams();
const activeStreamIds = new Set<string>();
// 1. Process all streams: Ensure they are in correct state
streams.forEach(stream => {
activeStreamIds.add(stream.id);
this.createStreamIcons(stream); // createStreamIcons now handles both create and remove-if-hidden
});
// 2. Remove icons for streams that no longer exist in settings (deleted)
for (const trackedId of this.ribbonIconsByStream.keys()) {
if (!activeStreamIds.has(trackedId)) {
this.log(`[RibbonService-${this.instanceId}] Removing orphan icons for stream ${trackedId}`);
this.removeStreamIcons(trackedId);
}
}
// Final Policing to catch any discrepancies
this.enforceRibbonConsistency();
}
private initializeStreamCommands(): void {
@ -140,7 +337,9 @@ export class RibbonService extends SettingsAwareSliceService {
}
private updateStreamCommands(stream: Stream): void {
if (stream.addCommand) {
if (!stream || !stream.id) return;
if (stream.addCommand && !stream.disabled) {
this.addStreamCommand(stream);
} else {
this.removeStreamCommand(stream.id);

View file

@ -0,0 +1,237 @@
import { RibbonService } from '../RibbonService';
import { Stream, StreamsSettings } from '../../../shared/types';
import { Plugin } from 'obsidian';
// Mock dependencies
jest.mock('../../../shared/EventBus', () => ({
eventBus: {
subscribe: jest.fn(),
emit: jest.fn()
},
EVENTS: {
STREAM_ADDED: 'STREAM_ADDED',
STREAM_UPDATED: 'STREAM_UPDATED',
STREAM_REMOVED: 'STREAM_REMOVED',
SETTINGS_CHANGED: 'SETTINGS_CHANGED'
}
}));
describe('RibbonService', () => {
let ribbonService: RibbonService;
let mockPlugin: any;
let mockApp: any;
let mockSettings: StreamsSettings;
beforeEach(() => {
// Reset DOM
document.body.innerHTML = '';
// Mock App
mockApp = {};
// Mock Settings
mockSettings = {
streams: [],
primaryStreamId: null,
reuseCurrentTab: false,
// ... other settings as needed ...
} as any;
// Mock Plugin
mockPlugin = {
app: mockApp,
settings: mockSettings,
addRibbonIcon: jest.fn().mockImplementation((icon, title, callback) => {
const el = document.createElement('div');
el.className = 'side-dock-ribbon-action';
el.setAttribute('aria-label', title);
// Mock Obsidian DOM extension methods
(el as any).addClass = (cls: string) => el.classList.add(cls);
(el as any).removeClass = (cls: string) => el.classList.remove(cls);
(el as any).toggleClass = (cls: string, toggle: boolean) => el.classList.toggle(cls, toggle);
document.body.appendChild(el);
return el;
}),
addCommand: jest.fn(),
saveSettings: jest.fn(),
};
ribbonService = new RibbonService();
ribbonService.setPlugin(mockPlugin as any);
});
afterEach(() => {
ribbonService.cleanup();
jest.clearAllMocks();
});
describe('enforceRibbonConsistency (Policing)', () => {
it('should remove icons for disabled streams', async () => {
// Setup: Create a ghost icon for a disabled stream
const ghostIcon = document.createElement('div');
ghostIcon.className = 'streams-ribbon-icon';
ghostIcon.setAttribute('data-stream-id', 'disabled-stream');
document.body.appendChild(ghostIcon);
// Configure settings: Stream exists but is disabled
const disabledStream: Stream = {
id: 'disabled-stream',
name: 'Disabled Stream',
icon: 'calendar',
showTodayInRibbon: true,
disabled: true, // DISABLED
addCommand: false,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
};
mockSettings.streams = [disabledStream];
// Act: Run update (triggers policing)
ribbonService.updateAllRibbonIcons();
// Assert: Icon should be gone
expect(document.body.contains(ghostIcon)).toBe(false);
});
it('should remove icons for hidden streams (showTodayInRibbon: false)', async () => {
// Setup: Create a ghost icon
const ghostIcon = document.createElement('div');
ghostIcon.className = 'streams-ribbon-icon';
ghostIcon.setAttribute('data-stream-id', 'hidden-stream');
document.body.appendChild(ghostIcon);
// Configure settings: Stream exists but hidden
const hiddenStream: Stream = {
id: 'hidden-stream',
name: 'Hidden Stream',
icon: 'calendar',
showTodayInRibbon: false, // HIDDEN
disabled: false,
addCommand: false,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
};
mockSettings.streams = [hiddenStream];
// Act
ribbonService.updateAllRibbonIcons();
// Assert
expect(document.body.contains(ghostIcon)).toBe(false);
});
it('should remove icons for non-existent streams (pure ghosts)', async () => {
const ghostIcon = document.createElement('div');
ghostIcon.className = 'streams-ribbon-icon';
ghostIcon.setAttribute('data-stream-id', 'unknown-stream');
document.body.appendChild(ghostIcon);
mockSettings.streams = [];
ribbonService.updateAllRibbonIcons();
expect(document.body.contains(ghostIcon)).toBe(false);
});
it('should remove legacy untagged icons by name', async () => {
// Setup: Create an untagged icon with aria-label
const ancientIcon = document.createElement('div');
ancientIcon.className = 'side-dock-ribbon-action'; // Classic obsidian class
ancientIcon.setAttribute('aria-label', 'Open today for Legacy Stream');
document.body.appendChild(ancientIcon);
// Settings: Stream exists but is hidden (so it should strip the icon)
const legacyStream: Stream = {
id: 'legacy-stream',
name: 'Legacy Stream',
icon: 'calendar',
showTodayInRibbon: false, // Should NOT be there
disabled: false,
addCommand: false,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
};
mockSettings.streams = [legacyStream];
ribbonService.updateAllRibbonIcons();
expect(document.body.contains(ancientIcon)).toBe(false);
});
it('should KEEP valid icons', async () => {
const validStream: Stream = {
id: 'valid-stream',
name: 'Valid Stream',
icon: 'calendar',
showTodayInRibbon: true,
disabled: false,
addCommand: false,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
};
mockSettings.streams = [validStream];
// Act: Create icons normally
ribbonService.updateAllRibbonIcons();
// Assert: Document should have 1 icon
const icons = document.querySelectorAll('.streams-ribbon-icon');
expect(icons.length).toBe(1);
expect(icons[0].getAttribute('data-stream-id')).toBe('valid-stream');
});
it('should remove duplicate/stale icons for valid streams', async () => {
const validStream: Stream = {
id: 'valid-stream',
name: 'Valid Stream',
icon: 'calendar',
showTodayInRibbon: true,
disabled: false,
addCommand: false,
encryptThisStream: false,
dateFormat: 'YYYY-MM-DD',
};
mockSettings.streams = [validStream];
// Helper to spy on creation
// We want to simulate a situation where an extra icon exists.
// 1. Manually inject a "stale" icon for this stream
const staleIcon = document.createElement('div');
staleIcon.className = 'streams-ribbon-icon';
staleIcon.setAttribute('data-stream-id', 'valid-stream');
staleIcon.innerHTML = 'old';
document.body.appendChild(staleIcon);
// 2. Run update. This will create a NEW icon (the "Blessed" one)
// and the Policeman should delete the OLD one.
ribbonService.updateAllRibbonIcons();
const icons = document.querySelectorAll('.streams-ribbon-icon');
// Should have 1 icon (the new one)
expect(icons.length).toBe(1);
// The surviving icon should NOT be the stale one
expect(icons[0]).not.toBe(staleIcon);
expect(icons[0].innerHTML).not.toBe('old');
});
});
});

View file

@ -0,0 +1,74 @@
import { App, Modal, Setting, TextComponent, getIconIds, setIcon } from 'obsidian';
import { LucideIcon } from '../../shared/types';
export class IconPickerModal extends Modal {
private onChoose: (icon: LucideIcon) => void;
private currentIcon: string;
private searchQuery: string = '';
private iconGrid: HTMLElement;
private allIcons: string[];
constructor(app: App, currentIcon: string, onChoose: (icon: LucideIcon) => void) {
super(app);
this.currentIcon = currentIcon;
this.onChoose = onChoose;
this.allIcons = getIconIds();
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('streams-icon-picker-modal');
// Header and Search
const header = contentEl.createDiv('streams-icon-picker-header');
header.createEl('h2', { text: 'Select Icon' });
const searchContainer = header.createDiv('streams-search-container');
new TextComponent(searchContainer)
.setPlaceholder('Search icons...')
.setValue(this.searchQuery)
.onChange((value) => {
this.searchQuery = value.toLowerCase();
this.renderIcons();
});
// Icon Grid
this.iconGrid = contentEl.createDiv('streams-icon-picker-grid');
this.renderIcons();
}
private renderIcons() {
this.iconGrid.empty();
const filteredIcons = this.allIcons.filter(icon =>
icon.toLowerCase().includes(this.searchQuery)
);
filteredIcons.forEach(icon => {
const iconItem = this.iconGrid.createDiv('streams-icon-item');
if (icon === this.currentIcon) {
iconItem.addClass('selected');
}
const iconContainer = iconItem.createDiv('streams-icon-preview');
setIcon(iconContainer, icon);
iconItem.setAttribute('aria-label', icon);
iconItem.addEventListener('click', () => {
this.onChoose(icon as LucideIcon);
this.close();
});
});
if (filteredIcons.length === 0) {
this.iconGrid.createDiv('streams-no-icons').setText('No icons found');
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -1,4 +1,4 @@
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
import { App, PluginSettingTab, Setting, Notice, ButtonComponent } from 'obsidian';
import { SettingsAwareSliceService } from '../../shared/BaseSlice';
import { Stream, StreamsSettings, LucideIcon } from '../../shared/types';
import { StreamsPluginInterface, SettingsManager, UIController, LogProvider } from '../../shared/interfaces';
@ -6,6 +6,8 @@ import { eventBus, EVENTS } from '../../shared/EventBus';
import { centralizedLogger } from '../../shared/CentralizedLogger';
import { configurationService } from '../../shared/ConfigurationService';
import { MeldDetectionService } from '../../slices/meld-integration';
import { IconPickerModal } from './IconPickerModal';
import { resolveStreamFilePath } from '../file-operations/streamUtils';
export class SettingsService extends SettingsAwareSliceService {
private settingsTab: StreamsSettingTab | null = null;
@ -75,7 +77,7 @@ export class StreamsSettingTab extends PluginSettingTab {
new Notice(`Streams bar component ${value ? 'shown' : 'hidden'}`);
}));
new Setting(containerEl)
.setName('Reuse current tab for calendar navigation')
.setDesc('When enabled, calendar navigation will reuse the current tab instead of opening new tabs')
@ -89,7 +91,7 @@ export class StreamsSettingTab extends PluginSettingTab {
new Notice(`Calendar navigation will ${value ? 'reuse' : 'open new'} tabs`);
}));
new Setting(containerEl)
.setName('Enable debug logging')
.setDesc('Enable debug logging for the Streams plugin (can also be toggled via command palette)')
@ -98,6 +100,7 @@ export class StreamsSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.settingsManager.settings.debugLoggingEnabled = value;
// Toggle Plugin Logger
if (this.logProvider.log) {
if (value) {
this.logProvider.log.on();
@ -106,29 +109,42 @@ export class StreamsSettingTab extends PluginSettingTab {
}
}
// Toggle Centralized Logger (used by slices)
if (value) {
centralizedLogger.enable();
} else {
centralizedLogger.disable();
}
await this.settingsManager.saveSettings();
new Notice(`Debug logging ${value ? 'enabled' : 'disabled'}`);
}));
new Setting(containerEl)
.setName('Bar style')
.setDesc('Choose the visual style for the streams bar component')
.addDropdown(dropdown => dropdown
.addOption('default', 'Default')
.addOption('modern', 'Modern')
.setValue(this.settingsManager.settings.barStyle)
.onChange(async (value: 'default' | 'modern') => {
this.settingsManager.settings.barStyle = value;
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
new Notice(`Bar style changed to ${value === 'default' ? 'Default' : 'Modern'}`);
}));
new Setting(containerEl).setName('Streams').setHeading();
new Setting(containerEl)
.setName('Primary stream')
.setDesc('Used by the "Go to primary stream" command')
.addDropdown(dropdown => {
const current = this.settingsManager.settings.primaryStreamId ?? '';
dropdown.addOption('', 'Not set');
// Only offer enabled streams; primary stream must be a single usable stream.
this.settingsManager.settings.streams
.filter(s => !s.disabled)
.forEach(stream => dropdown.addOption(stream.id, stream.name));
dropdown.setValue(current);
dropdown.onChange(async (value) => {
this.settingsManager.settings.primaryStreamId = value ? value : null;
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
});
});
new Setting(containerEl)
.setName('Add stream')
.setDesc('Create a new note stream')
@ -144,7 +160,8 @@ export class StreamsSettingTab extends PluginSettingTab {
showTodayInRibbon: true,
addCommand: false,
encryptThisStream: false,
disabled: false
disabled: false,
dateFormat: 'YYYY-MM-DD'
};
this.settingsManager.settings.streams.push(newStream);
await this.settingsManager.saveSettings();
@ -162,29 +179,32 @@ export class StreamsSettingTab extends PluginSettingTab {
private createStreamCard(container: HTMLElement, stream: Stream, index: number): HTMLElement {
const card = container.createDiv('streams-plugin-card');
// Add disabled class if stream is disabled
if (stream.disabled) {
card.addClass('streams-plugin-card-disabled');
}
// Create header with title and reorder controls
const header = card.createDiv('streams-card-header');
const title = header.createEl('h3', { text: stream.name });
// Add reorder controls to the header
const reorderContainer = header.createDiv('streams-reorder-container');
// Move up button - create simple HTML button
const upButton = reorderContainer.createEl('button', {
cls: 'streams-reorder-btn streams-caret-up',
attr: {
'data-action': 'move-up',
'title': 'Move stream up'
'title': 'Move stream up',
'type': 'button'
}
});
if (index === 0) upButton.disabled = true;
upButton.addEventListener('click', async () => {
upButton.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await this.moveStreamUp(index);
});
@ -193,53 +213,160 @@ export class StreamsSettingTab extends PluginSettingTab {
cls: 'streams-reorder-btn streams-caret-down',
attr: {
'data-action': 'move-down',
'title': 'Move stream down'
'title': 'Move stream down',
'type': 'button'
}
});
if (index === this.settingsManager.settings.streams.length - 1) downButton.disabled = true;
downButton.addEventListener('click', async () => {
downButton.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await this.moveStreamDown(index);
});
// Add status toggle
const statusToggle = reorderContainer.createEl('button', {
cls: `streams-status-toggle ${stream.disabled ? 'is-disabled' : 'is-enabled'}`,
text: stream.disabled ? 'DISABLED' : 'ENABLED',
attr: {
'title': stream.disabled ? 'Click to enable stream' : 'Click to disable stream',
'type': 'button'
}
});
statusToggle.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const newValue = !stream.disabled;
stream.disabled = newValue;
if (newValue && this.settingsManager.settings.primaryStreamId === stream.id) {
this.settingsManager.settings.primaryStreamId = null;
new Notice('Primary stream was disabled and has been cleared.');
}
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
// Update DOM directly instead of calling this.display() to prevent losing focus
statusToggle.className = `streams-status-toggle ${stream.disabled ? 'is-disabled' : 'is-enabled'}`;
statusToggle.textContent = stream.disabled ? 'DISABLED' : 'ENABLED';
statusToggle.title = stream.disabled ? 'Click to enable stream' : 'Click to disable stream';
if (stream.disabled) {
card.addClass('streams-plugin-card-disabled');
} else {
card.removeClass('streams-plugin-card-disabled');
}
requestAnimationFrame(() => {
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
});
});
return card;
}
private addStreamSettings(container: HTMLElement, stream: Stream, index: number): void {
// Stream name
new Setting(container)
.setClass('streams-setting-stacked')
.setName('Stream name')
.setDesc('Name of the stream')
.addText(text => text
.setValue(stream.name)
.onChange(async (value) => {
stream.name = value;
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
}));
// Stream folder
new Setting(container)
.setName('Folder')
.setDesc('Folder where daily notes will be created')
.addText(text => text
.setValue(stream.folder)
.onChange(async (value) => {
stream.folder = value;
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
}));
// File path template
const dateFormatDesc = document.createDocumentFragment();
dateFormatDesc.append(
'Advanced path support, i.e.:',
document.createElement('br'),
document.createElement('br'),
'/path/{YYYY-MM-DD}',
document.createElement('br'),
'/path/{YYYY}/{MM}/{DD}',
document.createElement('br'),
'/path/{YYYY}/deeper/{MM}/{DD}',
document.createElement('br'),
document.createElement('br')
);
const previewSpan = document.createElement('strong');
previewSpan.textContent = resolveStreamFilePath(stream, new Date());
dateFormatDesc.append(
'Preview: ',
document.createElement('br'),
previewSpan,
document.createElement('br'),
document.createElement('br'),
document.createElement('i').appendChild(document.createTextNode('*YYYY, MM, and DD must all be present'))
);
// Show today in ribbon
new Setting(container)
.setName('Show today button in ribbon')
.setDesc('Show a today button for this stream in the ribbon')
.addToggle(toggle => toggle
.setValue(stream.showTodayInRibbon)
.onChange(async (value) => {
stream.showTodayInRibbon = value;
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
}));
.setClass('streams-setting-stacked')
.setName('File path template')
.setDesc(dateFormatDesc)
.addText(text => {
const settingInput = text.inputEl;
text.setValue(stream.dateFormat || 'YYYY-MM-DD')
.setPlaceholder('my/folder/{YYYY-MM-DD}')
.onChange(async (value) => {
const missingTokens = [];
if (!value.includes('YYYY')) missingTokens.push('YYYY');
if (!value.includes('MM')) missingTokens.push('MM');
if (!value.includes('DD')) missingTokens.push('DD');
if (missingTokens.length > 0) {
// Invalid format, visually indicate error
settingInput.style.borderColor = 'var(--text-error)';
const tokenString = missingTokens.join(', ');
previewSpan.textContent = `Error: Missing required tokens (${tokenString})!`;
previewSpan.style.color = 'var(--text-error)';
return; // PREVENT SAVE
}
// Valid format, clear errors
settingInput.style.borderColor = '';
previewSpan.style.color = '';
stream.dateFormat = value;
previewSpan.textContent = resolveStreamFilePath(stream, new Date());
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
});
});
// Icon
const iconSetting = new Setting(container)
.setName('Icon')
.setDesc('Icon for the stream (used in ribbon and menus)')
.addButton(button => {
button
.setIcon(stream.icon)
.setTooltip('Select icon')
.onClick(() => {
const modal = new IconPickerModal(
this.app,
stream.icon,
async (newIcon) => {
stream.icon = newIcon;
button.setIcon(newIcon);
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
}
);
modal.open();
});
});
// Add command
new Setting(container)
@ -251,49 +378,45 @@ export class StreamsSettingTab extends PluginSettingTab {
stream.addCommand = value;
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
}));
// Show today in ribbon
new Setting(container)
.setName('Show in ribbon')
.setDesc('Show a today button for this stream in the ribbon')
.addToggle(toggle => toggle
.setValue(stream.showTodayInRibbon)
.onChange(async (value) => {
stream.showTodayInRibbon = value;
await this.settingsManager.saveSettings();
// Use STREAM_UPDATED for granular updates (avoiding full ribbon rebuilds)
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
}));
// Encrypt this stream
this.addEncryptionToggle(container, stream);
// Disable stream
const disableSetting = new Setting(container)
.setName('Disable stream')
.setDesc('When disabled, this stream will be hidden from selection lists and grayed out in settings')
.addToggle(toggle => toggle
.setValue(stream.disabled || false)
.onChange(async (value) => {
stream.disabled = value;
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
// Refresh the display to update visual styling
this.display();
// Force immediate UI refresh for mobile devices
// Use requestAnimationFrame to ensure DOM updates are processed
requestAnimationFrame(() => {
// Emit a specific event for stream dropdown refresh
eventBus.emit(EVENTS.STREAM_UPDATED, { streamId: stream.id, disabled: value }, 'settings-management');
});
}));
// Add a class to identify the disable toggle for styling
if (stream.disabled) {
disableSetting.settingEl.addClass('streams-disable-toggle');
}
// Remove stream
new Setting(container)
.addButton(button => button
.setButtonText('Remove stream')
.setWarning()
.onClick(async () => {
this.settingsManager.settings.streams.splice(index, 1);
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
this.display();
}));
const removeContainer = container.createDiv('streams-remove-button-container');
removeContainer.style.marginTop = '1em';
removeContainer.style.marginBottom = '1em';
new ButtonComponent(removeContainer)
.setButtonText('Remove stream')
.setWarning()
.onClick(async () => {
// If the removed stream was the primary stream, clear it.
if (this.settingsManager.settings.primaryStreamId === stream.id) {
this.settingsManager.settings.primaryStreamId = null;
new Notice('Primary stream was removed and has been cleared.');
}
this.settingsManager.settings.streams.splice(index, 1);
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
this.display();
});
}
private async moveStreamUp(index: number): Promise<void> {
@ -335,10 +458,10 @@ export class StreamsSettingTab extends PluginSettingTab {
const meldDetectionService = new MeldDetectionService();
meldDetectionService.setPlugin(this.settingsManager as any);
const isMeldAvailable = meldDetectionService.isMeldPluginAvailable();
const encryptionSetting = new Setting(container)
.setName('Encrypt this stream')
.setDesc(isMeldAvailable
.setDesc(isMeldAvailable
? 'When enabled, files created in this stream will be encrypted using the Meld plugin'
: 'Meld plugin is not available. Please install and enable the Meld plugin to use encryption features.'
)
@ -351,11 +474,11 @@ export class StreamsSettingTab extends PluginSettingTab {
new Notice('Meld plugin is not available. Please install and enable the Meld plugin first.');
return;
}
stream.encryptThisStream = value;
await this.settingsManager.saveSettings();
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
new Notice(`Encryption ${value ? 'enabled' : 'disabled'} for stream "${stream.name}"`);
});
});

View file

@ -23,7 +23,7 @@ export class StreamSelectionModal extends Modal {
enabledStreams.forEach(stream => {
new Setting(contentEl)
.setName(stream.name)
.setDesc(stream.folder)
.setDesc(stream.dateFormat)
.addButton(button => button
.setButtonText('Select')
.onClick(() => {

View file

@ -1,3 +1,10 @@
/*********************************************************
* CSS VARIABLES - STREAMS BAR DIMENSIONS
*********************************************************/
:root {
--streams-bar-widget-height: 28px;
}
/*********************************************************
* ANIMATIONS
*********************************************************/
@ -192,19 +199,38 @@
position: relative;
}
.streams-plugin-card-disabled::before {
content: "DISABLED";
position: absolute;
top: 10px;
right: 10px;
background-color: var(--text-error);
color: var(--text-on-accent);
.streams-status-toggle {
margin-left: 10px;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
border: none;
transition: all 0.2s ease;
height: auto;
line-height: 1.5;
box-shadow: none;
}
.streams-status-toggle.is-disabled {
background-color: var(--text-error);
color: var(--text-on-accent);
}
.streams-status-toggle.is-disabled:hover {
background-color: var(--text-error-hover);
}
.streams-status-toggle.is-enabled {
background-color: var(--interactive-success);
color: var(--text-on-accent);
}
.streams-status-toggle.is-enabled:hover {
background-color: var(--interactive-success-hover);
}
.streams-plugin-card-disabled h3 {
@ -219,10 +245,24 @@
pointer-events: none;
}
/* Allow the disable toggle itself to be clickable */
.streams-plugin-card-disabled .streams-disable-toggle .setting-item-control {
pointer-events: auto;
opacity: 1;
.setting-item.streams-setting-stacked {
flex-direction: column;
align-items: flex-start;
}
.setting-item.streams-setting-stacked .setting-item-info {
width: 100%;
margin-bottom: 8px;
}
.setting-item.streams-setting-stacked .setting-item-control {
width: 100%;
justify-content: flex-start;
margin-top: 0;
}
.setting-item.streams-setting-stacked .setting-item-control input[type="text"] {
width: 100%;
}
/*********************************************************
@ -231,7 +271,7 @@
.streams-bar-component {
position: relative;
width: 100%;
background-color: var(--background-primary);
background-color: transparent;
padding: 4px 6px 4px 6px;
font-size: 14px;
line-height: 1.4;
@ -239,33 +279,120 @@
pointer-events: auto;
display: flex;
align-items: center;
border-bottom: 1px solid var(--background-modifier-border);
backdrop-filter: blur(8px);
border-bottom: none;
backdrop-filter: none;
z-index: 1000;
order: 99;
flex-basis: 100%;
}
.streams-markdown-view-content {
position: relative;
}
.streams-markdown-view-content .streams-bar-component {
position: absolute;
top: 10px;
}
.workspace-leaf-content:has(.streams-create-file-container) {
.streams-create-file-leaf {
width: 100%;
}
/* Prevent scrollbars on CreateFileView */
.workspace-leaf-content:has(.streams-create-file-container) .view-content {
.streams-create-file-leaf .view-content {
overflow: hidden;
}
.streams-leaf-active .view-header {
z-index: 10000;
flex-wrap: wrap;
height: auto;
min-height: var(--header-height);
}
/* Ensure the native title container doesn't wrap to a new line, but shrinks and truncates as normal */
.streams-leaf-active .view-header>.view-header-title-container {
min-width: 0;
flex: 1 1 0;
}
/*
* HOLISTIC SCROLLING FIX (Desktop):
* Instead of making the header relative (which breaks immersive scrolling) or zeroing out padding,
* we let the header be native (absolute) so content scrolls under it.
* Since our Streams bar makes the header taller, we explicitly increase the padding-top
* on the markdown containers using an offset (40px).
*/
.mod-root .workspace-leaf-content.streams-leaf-active .view-content .markdown-reading-view>.markdown-preview-view,
.mod-root .workspace-leaf-content.streams-leaf-active .view-content .markdown-source-view>.cm-editor>.cm-scroller {
padding-top: calc(.5vh + var(--view-top-spacing-markdown, 0px)) !important;
}
/*
* HOLISTIC SCROLLING FIX (Mobile):
* Uses a slightly larger offset for touch targets (6vh).
*/
.is-phone .mod-root .workspace-leaf-content.streams-leaf-active .view-content .markdown-reading-view>.markdown-preview-view,
.is-phone .mod-root .workspace-leaf-content.streams-leaf-active .view-content .markdown-source-view>.cm-editor>.cm-scroller {
padding-top: calc(6vh + var(--view-top-spacing-markdown, 0px)) !important;
}
.is-phone .streams-bar-component {
font-size: 11px;
}
.streams-bar-component {
margin-top: -8px;
}
.streams-bar-collapsed {
background-color: transparent;
}
/*
* PILL STYLE MENU
* Wrap the two groups in pill-shaped containers to match Obsidian's native mobile menu buttons
*/
.streams-bar-nav-controls,
.streams-bar-change-stream {
background-color: var(--background-primary);
border-radius: var(--radius-xl, 20px);
padding: 4px 8px;
box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.05));
border: 1px solid var(--background-modifier-border);
height: 36px;
box-sizing: border-box;
}
/* Strip individual button borders/backgrounds to make them flow smoothly inside the pill */
.streams-bar-day-nav,
.streams-bar-today-button,
.streams-bar-home-button,
.streams-bar-settings-button {
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
height: auto;
padding: 4px;
}
.streams-bar-day-nav:hover,
.streams-bar-today-button:hover,
.streams-bar-home-button:hover,
.streams-bar-settings-button:hover {
background-color: transparent !important;
color: var(--text-accent);
}
.streams-bar-change-stream:hover {
background-color: var(--background-secondary) !important;
color: var(--text-accent);
border-color: var(--background-modifier-border);
}
.streams-bar-nav-controls {
flex: 0 0 auto;
/* Prevent stretching */
}
.streams-bar-change-stream {
margin-right: 0;
/* Push all the way to the right */
}
.streams-bar-component .streams-dropdown {
display: none;
}
@ -274,12 +401,14 @@
display: block;
}
.streams-bar-dropdown-hidden {
display: none !important;
body .streams-bar-dropdown-hidden,
.streams-bar-component .streams-bar-dropdown-hidden {
display: none;
}
.streams-bar-dropdown-visible {
display: block !important;
body .streams-bar-dropdown-visible,
.streams-bar-component .streams-bar-dropdown-visible {
display: block;
}
.streams-bar-component--visible {
@ -327,12 +456,15 @@
align-items: center;
justify-content: flex-start;
margin: 0;
background-color: transparent;
border-radius: 0;
padding: 0;
border: none;
gap: 8px;
flex: 1;
background-color: var(--background-primary);
border-radius: var(--radius-xl, 20px);
padding: 4px 8px;
box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.05));
border: 1px solid var(--background-modifier-border);
height: 36px;
box-sizing: border-box;
gap: 4px;
flex: 0 0 auto;
}
.streams-bar-nav {
@ -370,25 +502,25 @@
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
height: auto;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
transition: all 0.2s ease;
background-color: var(--interactive-normal);
border: 1px solid var(--background-modifier-border);
background-color: transparent;
border: none;
box-shadow: none;
font-size: 14px;
font-weight: 500;
}
.streams-bar-day-nav:hover {
background-color: var(--interactive-hover);
color: var(--text-normal);
border-color: var(--text-accent);
background-color: transparent;
color: var(--text-accent);
}
.streams-bar-day-nav:active {
background-color: var(--interactive-accent-hover);
background-color: transparent;
transform: scale(0.95);
}
@ -401,17 +533,18 @@
/* margin: 0 8px; */
font-size: 15px;
min-width: 60px;
padding: 2px 2px;
height: auto;
padding: 4px;
border-radius: 4px;
background-color: var(--interactive-normal);
border: 1px solid var(--background-modifier-border);
background-color: transparent;
border: none;
box-shadow: none;
cursor: pointer;
/* transition: all 0.2s ease; */
}
.streams-bar-today-button:hover {
background-color: var(--interactive-hover);
border-color: var(--text-accent);
background-color: transparent;
color: var(--text-accent);
}
@ -422,7 +555,7 @@
align-items: center;
justify-content: center;
padding: 0;
height: 28px;
height: var(--streams-bar-widget-height);
box-sizing: border-box;
padding-bottom: 3px;
}
@ -432,22 +565,25 @@
align-items: center;
justify-content: center;
gap: 6px;
padding: 5px 12px;
border-radius: 4px;
padding: 4px 16px;
height: 36px;
border-radius: var(--radius-xl, 20px);
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-muted);
margin: 0;
background-color: var(--interactive-normal);
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.05));
font-weight: 500;
position: relative;
box-sizing: border-box;
}
.streams-bar-change-stream:hover {
background-color: var(--interactive-hover);
color: var(--text-normal);
border-color: var(--text-accent);
background-color: var(--background-secondary);
color: var(--text-accent);
border-color: var(--background-modifier-border);
}
.streams-bar-change-stream-text {
@ -463,17 +599,18 @@
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
height: auto;
padding: 4px;
border-radius: 4px;
background-color: var(--interactive-normal);
border: 1px solid var(--background-modifier-border);
background-color: transparent;
border: none;
box-shadow: none;
color: var(--text-muted);
}
.streams-bar-home-button:hover {
background-color: var(--interactive-hover);
color: var(--text-normal);
border-color: var(--text-accent);
background-color: transparent;
color: var(--text-accent);
}
.streams-bar-settings-button {
@ -483,17 +620,18 @@
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
height: auto;
padding: 4px;
border-radius: 4px;
background-color: var(--interactive-normal);
border: 1px solid var(--background-modifier-border);
background-color: transparent;
border: none;
box-shadow: none;
color: var(--text-muted);
}
.streams-bar-settings-button:hover {
background-color: var(--interactive-hover);
color: var(--text-normal);
border-color: var(--text-accent);
background-color: transparent;
color: var(--text-accent);
}
/*********************************************************
@ -514,9 +652,9 @@
display: none;
}
.streams-bar-streams-dropdown.streams-bar-dropdown-visible,
.streams-bar-streams-dropdown.streams-dropdown--visible {
display: block !important;
.streams-bar-component .streams-bar-streams-dropdown.streams-bar-dropdown-visible,
.streams-bar-component .streams-bar-streams-dropdown.streams-dropdown--visible {
display: block;
}
.streams-bar-stream-item {
@ -586,6 +724,33 @@
color: var(--text-on-accent);
}
/* Primary stream indicator (subtle) */
.streams-bar-stream-item-primary-indicator {
width: 6px;
height: 6px;
border-radius: 999px;
margin-left: 8px;
background: var(--text-muted);
opacity: 0.45;
flex: 0 0 auto;
}
.streams-bar-stream-item:hover .streams-bar-stream-item-primary-indicator {
opacity: 0.65;
}
.streams-bar-stream-item-selected .streams-bar-stream-item-primary-indicator {
background: var(--text-on-accent);
opacity: 0.7;
}
/* Slightly larger in modern style so it still reads as a dot */
.streams-bar-component.modern-style .streams-bar-stream-item-primary-indicator {
width: 7px;
height: 7px;
opacity: 0.4;
}
/* Encryption icon for main stream display */
.streams-bar-encryption-icon {
display: flex;
@ -1012,10 +1177,21 @@
.is-phone .streams-bar-component.modern-style {
backdrop-filter: none;
background: transparent;
border-bottom: none;
font-size: 11px;
padding: 6px 10px;
}
.is-phone .streams-bar-component.modern-style .streams-bar-collapsed {
background: transparent;
}
.is-phone .streams-bar-component.modern-style .streams-bar-change-stream {
margin-right: 25px;
}
/*********************************************************
* STREAMS BAR - NAVIGATION & HEADER (MODERN STYLE)
*********************************************************/
@ -1576,10 +1752,16 @@
}
.is-phone .streams-bar-component.modern-style {
position: absolute;
top: 45px;
backdrop-filter: none;
background: transparent;
border-bottom: none;
font-size: 11px;
padding: 4px 6px;
margin: 0 2px;
width: calc(100% - 4px);
z-index: 10001;
}
@ -1631,16 +1813,18 @@
position: relative;
}
.streams-empty-state-hidden {
display: none !important;
body .streams-empty-state-hidden,
div.streams-empty-state-hidden {
display: none;
visibility: hidden;
opacity: 0;
height: 0;
overflow: hidden;
}
.streams-date-setting-hidden {
display: none !important;
body .streams-date-setting-hidden,
div.streams-date-setting-hidden {
display: none;
}
.streams-install-meld-content {
@ -1811,12 +1995,12 @@
/*********************************************************
* ENCRYPTED CREATE FILE VIEW STYLES
*********************************************************/
.workspace-leaf-content:has(.streams-create-file-encrypted-container) {
.streams-create-file-encrypted-leaf {
width: 100%;
}
/* Prevent scrollbars on EncryptedCreateFileView */
.workspace-leaf-content:has(.streams-create-file-encrypted-container) .view-content {
.streams-create-file-encrypted-leaf .view-content {
overflow: hidden;
}
@ -1979,4 +2163,109 @@
.is-phone .streams-create-file-encrypted-button {
padding: 10px 24px;
font-size: 0.95em;
}
/*********************************************************
* ICON PICKER MODAL
*********************************************************/
.streams-icon-picker-modal {
max-width: 500px;
width: 70vw;
max-height: 70vh;
height: auto;
}
.streams-icon-picker-header {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.streams-icon-picker-header h2 {
margin: 0;
}
.streams-search-container input {
width: 100%;
padding: 8px;
font-size: 14px;
}
.streams-icon-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
gap: 4px;
padding: 4px;
overflow-y: auto;
overflow-x: hidden;
max-height: 50vh;
background-color: var(--background-secondary);
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
box-sizing: border-box;
width: 100%;
}
.streams-icon-item {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border-radius: 4px;
cursor: pointer;
background-color: var(--background-primary);
border: 1px solid transparent;
transition: all 0.2s ease;
padding: 4px;
}
.streams-icon-item:hover {
background-color: var(--interactive-hover);
border-color: var(--text-accent);
}
.streams-icon-item.selected {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.streams-icon-preview {
display: flex;
align-items: center;
justify-content: center;
}
.streams-icon-preview svg {
width: 18px;
height: 18px;
}
/* Tooltip on hover */
.streams-icon-item {
position: relative;
}
.streams-icon-item:hover::after {
content: attr(aria-label);
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: var(--background-modifier-message);
color: var(--text-normal);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.streams-no-icons {
grid-column: 1 / -1;
padding: 40px;
text-align: center;
color: var(--text-muted);
}

View file

@ -1,3 +1,3 @@
{
"1.0.0": "0.15.0"
}
}