mirror of
https://github.com/bfloydd/streams.git
synced 2026-07-22 12:50:25 +00:00
Compare commits
33 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab2e58bd85 | ||
|
|
a9cde02c78 | ||
|
|
37eeb11247 | ||
|
|
f1afd09bda | ||
|
|
f85a795dcc | ||
|
|
4e109b0798 | ||
|
|
d7ce138f91 | ||
|
|
24c52fa626 | ||
|
|
a120018138 | ||
|
|
ee989d755c | ||
|
|
2766228af4 | ||
|
|
4577611ce5 | ||
|
|
8eebf31015 | ||
|
|
335e05567f | ||
|
|
cd83a82c58 | ||
|
|
74cf520b4a | ||
|
|
c91b2a1460 | ||
|
|
5dfdc808ea | ||
|
|
6d0d067071 | ||
|
|
3866e55779 | ||
|
|
f6f92ce247 | ||
|
|
654ba6f8f7 | ||
|
|
ae909be484 | ||
|
|
5c489e3bb3 | ||
|
|
748bb00644 | ||
|
|
7bedfbdd9c | ||
|
|
0a23247f59 | ||
|
|
2c7bbfd1fa | ||
|
|
2672340764 | ||
|
|
d82be7a37a | ||
|
|
c751370c60 | ||
|
|
46d2ed166c | ||
|
|
1f3999373c |
37 changed files with 795 additions and 427 deletions
41
.agent/skills/obsidian-plugin/SKILL.md
Normal file
41
.agent/skills/obsidian-plugin/SKILL.md
Normal 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
47
.github/workflows/release.yml
vendored
Normal 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
57
CONTRIBUTING.md
Normal 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.
|
||||
63
adr/0001-leaf-selection-behavior.md
Normal file
63
adr/0001-leaf-selection-behavior.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# ADR-0001: Leaf selection behavior for stream navigation
|
||||
|
||||
**Date:** 2026-03-25
|
||||
**Status:** Accepted
|
||||
**Affects:** `LeafSelectionService.ts`, `RibbonService.ts`, `OpenTodayStreamCommand.ts`
|
||||
|
||||
## Context
|
||||
|
||||
When a user clicks a ribbon icon (or runs a command) to open a stream's daily note, the plugin must decide *which* workspace tab (leaf) to open the file in. Obsidian's workspace has three areas:
|
||||
|
||||
- **Main editor area** (`workspace.rootSplit`) — the central pane(s) where notes are edited
|
||||
- **Left sidebar** (`workspace.leftSplit`) — file explorer, bookmarks, etc.
|
||||
- **Right sidebar** (`workspace.rightSplit`) — backlinks, outgoing links, etc.
|
||||
|
||||
Prior to this decision, the plugin had several issues:
|
||||
|
||||
1. **Pinned tabs were overwritten** — clicking a ribbon icon while a pinned tab was active would load the stream note into that pinned tab, violating user intent.
|
||||
2. **New tabs were always created** — when the active tab couldn't be reused (e.g., it was pinned), the plugin created a brand-new tab rather than reusing an existing unpinned one, leading to tab proliferation.
|
||||
3. **Side panes could be targeted** — the leaf selection logic didn't distinguish between main editor leaves and sidebar leaves, so a stream note could open inside a sidebar panel.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Never navigate into pinned tabs
|
||||
|
||||
All leaf selection paths check `(leaf as any).pinned === true` before reusing a leaf. If the active leaf is pinned, it is skipped entirely.
|
||||
|
||||
### 2. Prefer reusing an existing unpinned tab over creating a new one
|
||||
|
||||
When the active leaf can't be used (pinned or in a side pane), `findNextUnpinnedLeaf()` scans the workspace for the first unpinned, eligible leaf in the main editor area. A new tab is only created as a last resort when no candidate is found.
|
||||
|
||||
### 3. Restrict all leaf selection to the main editor area
|
||||
|
||||
Every leaf candidate is checked via `leaf.getRoot() === app.workspace.rootSplit`. Leaves in left/right sidebars are never selected for navigation. This applies to:
|
||||
|
||||
- `findNextUnpinnedLeaf()` — only scans main editor leaves
|
||||
- `reuseCurrentLeaf()` — won't reuse the active leaf if it's in a sidebar
|
||||
- `selectOrCreateLeaf()` — same guard
|
||||
- `selectLeafForFile()` — won't match existing leaves in sidebars
|
||||
|
||||
### 4. Ctrl/Cmd+click forces a new tab
|
||||
|
||||
Ribbon icon callbacks capture the `MouseEvent`. When `ctrlKey` (Windows/Linux) or `metaKey` (macOS) is held, a fresh leaf is created via `app.workspace.getLeaf('tab')` and passed as `targetLeaf`, bypassing all reuse logic. This matches standard Obsidian/browser conventions.
|
||||
|
||||
## Leaf selection priority
|
||||
|
||||
For a normal click, the selection order is:
|
||||
|
||||
1. **Active leaf** — if it's unpinned, in the main editor area, and passes any view-type filter
|
||||
2. **Existing leaf with same file** — (for `selectLeafForFile` only) if already open in the main editor
|
||||
3. **Next unpinned leaf** — first unpinned leaf found in the main editor area
|
||||
4. **New tab** — created via `getLeaf('tab')` as a last resort
|
||||
|
||||
For Ctrl/Cmd+click:
|
||||
|
||||
1. **New tab** — always, via `getLeaf('tab')` passed as `targetLeaf`
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Pinned tabs are always respected.** Users can pin a tab and know it won't be overwritten by ribbon navigation.
|
||||
- **Tab count stays manageable.** Reusing unpinned tabs prevents accumulation of redundant tabs.
|
||||
- **Sidebar integrity is preserved.** File explorer, backlinks, and other sidebar views are never replaced with stream content.
|
||||
- **Ctrl+click provides an escape hatch.** Users who *do* want a new tab can explicitly request one.
|
||||
- **Relies on internal API.** The `pinned` property on `WorkspaceLeaf` is not part of Obsidian's public TypeScript API. This is a commonly used internal property but could change in future Obsidian versions.
|
||||
42
adr/0002-ribbon-click-behavior.md
Normal file
42
adr/0002-ribbon-click-behavior.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# ADR-0002: Ribbon click behavior
|
||||
|
||||
**Date:** 2026-03-25
|
||||
**Status:** Accepted
|
||||
**Affects:** `RibbonService.ts`, `OpenTodayStreamCommand.ts`, `OpenTodayPrimaryStreamCommand.ts`
|
||||
|
||||
## Context
|
||||
|
||||
Users interact with stream ribbon icons to quickly open a stream's daily note. Before this change, every click behaved identically — there was no way to control *how* the note opened (reuse a tab vs. open a new one) at click time. Users who wanted to open the same stream in a second tab had no mechanism to do so from the ribbon.
|
||||
|
||||
Standard Obsidian and browser conventions use **Ctrl+click** (or **Cmd+click** on macOS) to force-open a link in a new tab.
|
||||
|
||||
## Decision
|
||||
|
||||
### Normal click (no modifier)
|
||||
|
||||
Follows the reuse-first strategy defined in [ADR-0001](0001-leaf-selection-behavior.md):
|
||||
|
||||
1. Reuse the active leaf if it's unpinned and in the main editor area
|
||||
2. Otherwise find an existing unpinned leaf in the main editor area
|
||||
3. Create a new tab only as a last resort
|
||||
|
||||
This respects the user's `reuseCurrentTab` setting and keeps tab count manageable.
|
||||
|
||||
### Ctrl+click / Cmd+click
|
||||
|
||||
Always opens in a **new tab**, bypassing all reuse logic:
|
||||
|
||||
1. The ribbon callback captures the `MouseEvent`
|
||||
2. If `evt.ctrlKey` (Windows/Linux) or `evt.metaKey` (macOS) is `true`, a fresh leaf is created via `app.workspace.getLeaf('tab')`
|
||||
3. This leaf is passed as `targetLeaf` to the command, which forwards it to `openStreamDate()`
|
||||
4. When `targetLeaf` is provided, `LeafSelectionService` is not consulted at all
|
||||
|
||||
### Implementation
|
||||
|
||||
Both ribbon callbacks (`createAllStreamsIcon` and `createStreamIcons`) were updated from `() => { ... }` to `(evt: MouseEvent) => { ... }`. The `OpenTodayStreamCommand` constructor was extended with an optional `targetLeaf` parameter (matching `OpenTodayPrimaryStreamCommand`, which already had one).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Matches platform conventions.** Ctrl/Cmd+click for "open in new tab" is muscle memory for most users.
|
||||
- **No new settings required.** The modifier key approach is discoverable and doesn't add UI complexity.
|
||||
- **Commands (palette) are unaffected.** Only ribbon icon clicks receive the mouse event; command palette invocations continue to use the standard reuse-first strategy.
|
||||
40
adr/0003-stream-bar-date-sync.md
Normal file
40
adr/0003-stream-bar-date-sync.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
41
main.ts
41
main.ts
|
|
@ -45,13 +45,7 @@ export default class StreamsPlugin extends Plugin implements StreamsAPI {
|
|||
await this.saveSettings();
|
||||
}
|
||||
|
||||
// Migration: ensure barStyle exists
|
||||
if (!this.settings.barStyle) {
|
||||
this.settings.barStyle = 'default';
|
||||
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) {
|
||||
|
|
@ -62,6 +56,39 @@ 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 and calendarCompactState
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
{
|
||||
"id": "streams",
|
||||
"name": "Streams",
|
||||
"version": "1.2.9",
|
||||
"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
18
package-lock.json
generated
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,6 @@ export interface DefaultSettingsConfiguration {
|
|||
readonly reuseCurrentTab: boolean;
|
||||
|
||||
readonly debugLoggingEnabled: boolean;
|
||||
readonly barStyle: 'default';
|
||||
}
|
||||
|
||||
// Main configuration interface
|
||||
|
|
@ -109,8 +108,7 @@ export class ConfigurationService extends BaseSliceService implements Configurat
|
|||
showStreamsBarComponent: true,
|
||||
reuseCurrentTab: false,
|
||||
|
||||
debugLoggingEnabled: false,
|
||||
barStyle: 'default'
|
||||
debugLoggingEnabled: false
|
||||
} as const
|
||||
};
|
||||
|
||||
|
|
@ -226,8 +224,7 @@ export class ConfigurationService extends BaseSliceService implements Configurat
|
|||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ export const DEFAULT_SETTINGS = {
|
|||
primaryStreamId: null,
|
||||
showStreamsBarComponent: true,
|
||||
reuseCurrentTab: false,
|
||||
debugLoggingEnabled: false,
|
||||
barStyle: 'default' as const
|
||||
debugLoggingEnabled: false
|
||||
} as const;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ 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 {
|
||||
|
|
@ -41,7 +41,6 @@ export interface StreamsSettings {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -226,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();
|
||||
|
|
@ -242,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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -99,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) => {
|
||||
|
|
@ -115,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;
|
||||
}
|
||||
|
||||
|
|
@ -188,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
|
||||
|
|
@ -227,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();
|
||||
|
|
@ -319,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) {
|
||||
|
|
@ -542,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);
|
||||
|
|
@ -561,8 +556,5 @@ export class StreamsBarComponent extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
public refreshBarStyle(): void {
|
||||
this.stateManager.applyBarStyle(this.component);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
@ -116,16 +66,9 @@ export class ViewContainerService {
|
|||
const viewHeader = leafContainer.querySelector('.view-header');
|
||||
|
||||
if (viewHeader) {
|
||||
// On mobile/phone, append inside the view-header as the last child
|
||||
// On desktop, insert after the view-header
|
||||
if (Platform.isPhone) {
|
||||
viewHeader.appendChild(component);
|
||||
} else if (viewHeader.parentElement) {
|
||||
// Insert after the view-header for desktop
|
||||
viewHeader.parentElement.insertBefore(component, viewHeader.nextSibling);
|
||||
} else {
|
||||
viewHeader.appendChild(component);
|
||||
}
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -151,8 +151,7 @@ describe('ServiceCoordinator', () => {
|
|||
primaryStreamId: null,
|
||||
showStreamsBarComponent: true,
|
||||
reuseCurrentTab: false,
|
||||
debugLoggingEnabled: false,
|
||||
barStyle: 'default'
|
||||
debugLoggingEnabled: false
|
||||
};
|
||||
|
||||
serviceCoordinator.onSettingsChanged(newSettings);
|
||||
|
|
|
|||
|
|
@ -54,8 +54,7 @@ describe('Calendar Navigation Integration Tests', () => {
|
|||
|
||||
showStreamsBarComponent: true,
|
||||
reuseCurrentTab: false,
|
||||
debugLoggingEnabled: false,
|
||||
barStyle: 'default' as const
|
||||
debugLoggingEnabled: false
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,14 +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 formatString = this.stream.dateFormat || 'YYYY-MM-DD';
|
||||
const formattedDate = (window as any).moment(currentDate).format(formatString);
|
||||
const fileName = `${formattedDate}.md`;
|
||||
const streamFolder = this.stream.folder.replace(/\/$/, '');
|
||||
this.filePath = streamFolder ? `${streamFolder}/${fileName}` : fileName;
|
||||
this.filePath = resolveStreamFilePath(this.stream, currentDate);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -126,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);
|
||||
}
|
||||
|
||||
|
|
@ -139,18 +136,14 @@ export class CreateFileView extends ItemView {
|
|||
|
||||
|
||||
private handleDateChange(state: DateState): void {
|
||||
|
||||
// Update the file path based on the new date
|
||||
const formatString = this.stream.dateFormat || 'YYYY-MM-DD';
|
||||
const formattedDate = (window as any).moment(state.currentDate).format(formatString);
|
||||
const fileName = `${formattedDate}.md`;
|
||||
const streamFolder = this.stream.folder.replace(/\/$/, '');
|
||||
this.filePath = streamFolder ? `${streamFolder}/${fileName}` : 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -172,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,16 +118,13 @@ export class CreateFileViewEncrypted extends ItemView {
|
|||
|
||||
private handleDateChange(state: DateState): void {
|
||||
// Update the file path based on the new date
|
||||
const formatString = this.stream.dateFormat || 'YYYY-MM-DD';
|
||||
const formattedDate = (window as any).moment(state.currentDate).format(formatString);
|
||||
const fileName = `${formattedDate}.md`;
|
||||
const streamFolder = this.stream.folder.replace(/\/$/, '');
|
||||
this.filePath = streamFolder ? `${streamFolder}/${fileName}` : 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -148,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
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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';
|
||||
|
||||
|
|
@ -227,11 +228,7 @@ export class InstallMeldView extends ItemView {
|
|||
|
||||
private handleDateChange(state: DateState): void {
|
||||
// Update the file path based on the new date
|
||||
const formatString = this.stream.dateFormat || 'YYYY-MM-DD';
|
||||
const formattedDate = (window as any).moment(state.currentDate).format(formatString);
|
||||
const fileName = `${formattedDate}.mdenc`;
|
||||
const streamFolder = this.stream.folder.replace(/\/$/, '');
|
||||
this.filePath = streamFolder ? `${streamFolder}/${fileName}` : fileName;
|
||||
this.filePath = resolveStreamFilePath(this.stream, state.currentDate, 'mdenc');
|
||||
|
||||
// Refresh the view content
|
||||
if (this.contentEl) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ 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,
|
||||
|
|
@ -44,7 +44,7 @@ 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,
|
||||
|
|
@ -63,7 +63,7 @@ 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,
|
||||
|
|
@ -80,7 +80,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -156,17 +156,19 @@ 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) {
|
||||
const folderPath = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
await ensureFolderExists(app, folderPath);
|
||||
|
||||
const template = '';
|
||||
|
|
@ -176,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
|
||||
|
||||
|
|
@ -184,17 +244,10 @@ export async function openStreamDate(app: App, stream: Stream, date: Date = new
|
|||
return;
|
||||
}
|
||||
|
||||
const formatString = stream.dateFormat || 'YYYY-MM-DD';
|
||||
const formattedDate = (window as any).moment(date).format(formatString);
|
||||
const fileName = `${formattedDate}.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);
|
||||
|
|
|
|||
|
|
@ -90,12 +90,19 @@ export class RibbonService extends SettingsAwareSliceService {
|
|||
this.getPlugin().addRibbonIcon(
|
||||
'calendar',
|
||||
'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.getSettings().reuseCurrentTab,
|
||||
targetLeaf
|
||||
);
|
||||
command.execute();
|
||||
}
|
||||
|
|
@ -156,11 +163,18 @@ export class RibbonService extends SettingsAwareSliceService {
|
|||
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
|
||||
this.getSettings().reuseCurrentTab,
|
||||
targetLeaf
|
||||
);
|
||||
command.execute();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ describe('RibbonService', () => {
|
|||
mockSettings = {
|
||||
streams: [],
|
||||
primaryStreamId: null,
|
||||
barStyle: 'default',
|
||||
reuseCurrentTab: false,
|
||||
// ... other settings as needed ...
|
||||
} as any;
|
||||
|
|
@ -81,7 +80,7 @@ describe('RibbonService', () => {
|
|||
id: 'disabled-stream',
|
||||
name: 'Disabled Stream',
|
||||
icon: 'calendar',
|
||||
folder: 'foo',
|
||||
|
||||
|
||||
|
||||
showTodayInRibbon: true,
|
||||
|
|
@ -111,7 +110,7 @@ describe('RibbonService', () => {
|
|||
id: 'hidden-stream',
|
||||
name: 'Hidden Stream',
|
||||
icon: 'calendar',
|
||||
folder: 'foo',
|
||||
|
||||
|
||||
|
||||
showTodayInRibbon: false, // HIDDEN
|
||||
|
|
@ -154,7 +153,7 @@ describe('RibbonService', () => {
|
|||
id: 'legacy-stream',
|
||||
name: 'Legacy Stream',
|
||||
icon: 'calendar',
|
||||
folder: 'foo',
|
||||
|
||||
|
||||
|
||||
showTodayInRibbon: false, // Should NOT be there
|
||||
|
|
@ -175,7 +174,7 @@ describe('RibbonService', () => {
|
|||
id: 'valid-stream',
|
||||
name: 'Valid Stream',
|
||||
icon: 'calendar',
|
||||
folder: 'foo',
|
||||
|
||||
|
||||
|
||||
showTodayInRibbon: true,
|
||||
|
|
@ -200,7 +199,7 @@ describe('RibbonService', () => {
|
|||
id: 'valid-stream',
|
||||
name: 'Valid Stream',
|
||||
icon: 'calendar',
|
||||
folder: 'foo',
|
||||
|
||||
|
||||
|
||||
showTodayInRibbon: true,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -7,6 +7,7 @@ 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;
|
||||
|
|
@ -120,22 +121,6 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
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)
|
||||
|
|
@ -288,7 +273,6 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
new Setting(container)
|
||||
.setClass('streams-setting-stacked')
|
||||
.setName('Stream name')
|
||||
.setDesc('Name of the stream')
|
||||
.addText(text => text
|
||||
.setValue(stream.name)
|
||||
.onChange(async (value) => {
|
||||
|
|
@ -298,45 +282,67 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
|
||||
}));
|
||||
|
||||
// Stream folder
|
||||
new Setting(container)
|
||||
.setClass('streams-setting-stacked')
|
||||
.setName('Base 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');
|
||||
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
|
||||
}));
|
||||
|
||||
// Date format
|
||||
// File path template
|
||||
const dateFormatDesc = document.createDocumentFragment();
|
||||
dateFormatDesc.append(
|
||||
'Format string for the date file name (e.g. YYYY-MM-DD). Uses moment.js tokens.',
|
||||
'Advanced path support, i.e.:',
|
||||
document.createElement('br'),
|
||||
'Preview: ',
|
||||
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 = (window as any).moment().format(stream.dateFormat || 'YYYY-MM-DD');
|
||||
dateFormatDesc.append(previewSpan);
|
||||
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'))
|
||||
);
|
||||
|
||||
new Setting(container)
|
||||
.setClass('streams-setting-stacked')
|
||||
.setName('Date format')
|
||||
.setName('File path template')
|
||||
.setDesc(dateFormatDesc)
|
||||
.addText(text => text
|
||||
.setValue(stream.dateFormat || 'YYYY-MM-DD')
|
||||
.setPlaceholder('YYYY-MM-DD')
|
||||
.onChange(async (value) => {
|
||||
stream.dateFormat = value;
|
||||
previewSpan.textContent = (window as any).moment().format(value || 'YYYY-MM-DD');
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
|
||||
}));
|
||||
.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)
|
||||
|
|
@ -392,22 +398,25 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
this.addEncryptionToggle(container, stream);
|
||||
|
||||
// Remove stream
|
||||
new Setting(container)
|
||||
.addButton(button => button
|
||||
.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.');
|
||||
}
|
||||
const removeContainer = container.createDiv('streams-remove-button-container');
|
||||
removeContainer.style.marginTop = '1em';
|
||||
removeContainer.style.marginBottom = '1em';
|
||||
|
||||
this.settingsManager.settings.streams.splice(index, 1);
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
this.display();
|
||||
}));
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
232
styles.css
232
styles.css
|
|
@ -271,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;
|
||||
|
|
@ -279,50 +279,118 @@
|
|||
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;
|
||||
}
|
||||
|
||||
.is-phone .view-header {
|
||||
position: relative;
|
||||
.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 {
|
||||
position: absolute;
|
||||
top: 45px;
|
||||
backdrop-filter: none;
|
||||
background-color: transparent;
|
||||
border-bottom: none;
|
||||
font-size: 11px;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.is-phone .streams-bar-collapsed {
|
||||
.streams-bar-component {
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.streams-bar-collapsed {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.is-phone .streams-bar-change-stream {
|
||||
margin-right: 25px;
|
||||
/*
|
||||
* 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 {
|
||||
|
|
@ -333,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 {
|
||||
|
|
@ -386,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 {
|
||||
|
|
@ -429,25 +502,25 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: var(--streams-bar-widget-height);
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -460,18 +533,18 @@
|
|||
/* margin: 0 8px; */
|
||||
font-size: 15px;
|
||||
min-width: 60px;
|
||||
height: var(--streams-bar-widget-height);
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -492,23 +565,25 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
height: var(--streams-bar-widget-height);
|
||||
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 {
|
||||
|
|
@ -524,17 +599,18 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: var(--streams-bar-widget-height);
|
||||
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 {
|
||||
|
|
@ -544,17 +620,18 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: var(--streams-bar-widget-height);
|
||||
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);
|
||||
}
|
||||
|
||||
/*********************************************************
|
||||
|
|
@ -575,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 {
|
||||
|
|
@ -1100,14 +1177,11 @@
|
|||
|
||||
|
||||
.is-phone .streams-bar-component.modern-style {
|
||||
position: absolute;
|
||||
top: 45px;
|
||||
backdrop-filter: none;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.is-phone .streams-bar-component.modern-style .streams-bar-collapsed {
|
||||
|
|
@ -1739,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 {
|
||||
|
|
@ -1919,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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue