mirror of
https://github.com/wenlzhang/obsidian-folder-navigator.git
synced 2026-07-22 05:41:23 +00:00
Compare commits
21 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4175b9dc30 | ||
|
|
28db3e9d50 | ||
|
|
d6f18b52c2 | ||
|
|
95e5d2937f | ||
|
|
5780b3e8bc | ||
|
|
b1af049004 | ||
|
|
658adcbfe4 | ||
|
|
89df8eb024 | ||
|
|
d6de79d372 | ||
|
|
97974cc801 | ||
|
|
3b440f3222 | ||
|
|
bf52a4b2a5 | ||
|
|
78f03608ea | ||
|
|
1dd71601ce | ||
|
|
71294a3b94 | ||
|
|
5929cd9cf2 | ||
|
|
72506484ee | ||
|
|
99e1d036aa | ||
|
|
eb222d3e52 | ||
|
|
5cfe391aec | ||
|
|
79dc260bb7 |
12 changed files with 1260 additions and 78 deletions
46
CHANGELOG.md
46
CHANGELOG.md
|
|
@ -8,6 +8,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.5.0] - 2025-10-07
|
||||
|
||||
### Changes
|
||||
|
||||
- refactor: reorganize settings tab layout and improve section headings
|
||||
- feat: add create new folder option in folder navigator modal
|
||||
|
||||
## [0.4.1] - 2025-09-30
|
||||
|
||||
### Changes
|
||||
|
||||
- style: remove accent color from suggestion highlights
|
||||
|
||||
## [0.4.0] - 2025-09-30
|
||||
|
||||
### Changes
|
||||
|
||||
- refactor: improve folder search by excluding redundant child matches instead of deprioritizing
|
||||
- feat: implement keyword highlighting in folder search suggestions
|
||||
- refactor: remove unused match position tracking in folder search results
|
||||
- improve fuzzy search
|
||||
- Update README.md
|
||||
- Improve code format
|
||||
|
||||
## [0.3.1] - 2025-04-30
|
||||
|
||||
### Changes
|
||||
|
||||
- Refactor settings tab to dynamically show/hide folder history options based on mode
|
||||
- Rename FolderSortMode to FolderDisplayMode and update related UI text for clarity
|
||||
- Improve code format
|
||||
|
||||
## [0.3.0] - 2025-04-30
|
||||
|
||||
### Changes
|
||||
|
||||
- Add visual separators and confirmation modal for folder history reset
|
||||
- Add sort options
|
||||
- Add demo
|
||||
- Improve code format
|
||||
|
||||
## [0.2.1] - 2025-04-06
|
||||
|
||||
### Changes
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -4,12 +4,24 @@
|
|||
|
||||
An [Obsidian](https://obsidian.md) plugin that allows you to quickly navigate to folders in your vault using fuzzy search.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Quick folder navigation using fuzzy search
|
||||
- Configurable maximum number of search results
|
||||
- Expand target folder on navigation
|
||||
- Works with nested folders
|
||||
- Smart folder prioritization options:
|
||||
- Default order (alphabetical)
|
||||
- Recently visited folders displayed at the top
|
||||
- Frequently visited folders displayed at the top
|
||||
- Option to reset folder navigation history if needed
|
||||
|
||||
|
||||
## Videos and Articles
|
||||
|
||||
### Articles
|
||||
|
||||
- [The Strategy of Note Taking: Folders, Tags, Links, and Redundancy - PTKM](https://ptkm.net/blog-note-taking-strategy-folders-tags-links-redundancy)
|
||||
|
||||
## Why Folder Navigator?
|
||||
|
||||
|
|
|
|||
BIN
docs/attachment/demo.gif
Normal file
BIN
docs/attachment/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
169
docs/implementation/SEARCH_IMPROVEMENTS.md
Normal file
169
docs/implementation/SEARCH_IMPROVEMENTS.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Search Strategy Improvements
|
||||
|
||||
This document describes the improvements made to the folder search functionality to address issues #4 and #8.
|
||||
|
||||
## Issues Addressed
|
||||
|
||||
### Issue #4: Better Match Prioritization
|
||||
**Problem**: When searching for "abc", the plugin would show "abc" along with all its subdirectories like "abc/xxx", "abc/yyy", etc., making it difficult to find the target folder.
|
||||
|
||||
**Solution**: Implemented intelligent parent-child deduplication that:
|
||||
- Prioritizes parent folder matches over their children
|
||||
- Completely excludes child folders unless they have additional keyword matches beyond what the parent has
|
||||
- Only shows children when they contain additional matched keywords not found in the parent path
|
||||
|
||||
### Issue #8: Out-of-Order Keyword Matching
|
||||
**Problem**: Search only worked when keywords were in the correct order. For example, searching "bar foo" would not match "foo/bar/baz".
|
||||
|
||||
**Solution**: Implemented keyword-based matching that:
|
||||
- Splits the search query into space-separated keywords
|
||||
- Matches keywords independently in any order
|
||||
- Requires all keywords to be present in the folder path for a match
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Custom `getSuggestions` Method
|
||||
|
||||
The plugin now uses a custom `getSuggestions` method that overrides the default fuzzy search with a more sophisticated scoring system:
|
||||
|
||||
#### 1. Keyword Extraction
|
||||
```typescript
|
||||
const keywords = normalizedQuery.split(/\s+/).filter((k) => k.length > 0);
|
||||
```
|
||||
- Splits query by whitespace
|
||||
- Filters out empty strings
|
||||
- Allows matching keywords in any order
|
||||
|
||||
#### 2. Scoring System
|
||||
|
||||
Each folder is scored based on multiple factors:
|
||||
|
||||
**Keyword Match Bonuses:**
|
||||
- **+100**: Keyword matches in folder name (not just parent path)
|
||||
- **+200**: Exact folder name match
|
||||
- **+50**: Folder name starts with keyword
|
||||
- **+10**: Keyword only matches in parent path
|
||||
- **+0 to +50**: Earlier matches in path get higher scores
|
||||
|
||||
**Path-Based Adjustments:**
|
||||
- **-5 per level**: Penalty for nested depth (prefers shallower folders)
|
||||
- **+0 to +100**: Bonus for shorter paths (more specific matches)
|
||||
|
||||
#### 3. Parent-Child Deduplication
|
||||
|
||||
After initial scoring, the algorithm applies deduplication:
|
||||
|
||||
```typescript
|
||||
// Count total occurrences of all keywords in both parent and child
|
||||
let parentOccurrences = 0;
|
||||
let childOccurrences = 0;
|
||||
|
||||
for (const keyword of keywords) {
|
||||
// Count occurrences of this keyword in parent
|
||||
let pos = 0;
|
||||
while ((pos = parentLower.indexOf(keyword, pos)) !== -1) {
|
||||
parentOccurrences++;
|
||||
pos += keyword.length;
|
||||
}
|
||||
|
||||
// Count occurrences of this keyword in child
|
||||
pos = 0;
|
||||
while ((pos = childLower.indexOf(keyword, pos)) !== -1) {
|
||||
childOccurrences++;
|
||||
pos += keyword.length;
|
||||
}
|
||||
}
|
||||
|
||||
// If child doesn't have more keyword occurrences than parent, exclude it
|
||||
if (childOccurrences <= parentOccurrences) {
|
||||
shouldExclude = true;
|
||||
}
|
||||
```
|
||||
|
||||
This ensures that when searching for "abc":
|
||||
- "abc" is shown (1 occurrence)
|
||||
- "abc/xxx" is completely hidden (still 1 occurrence of "abc")
|
||||
- "abc/xxx/abc" would be shown (2 occurrences of "abc")
|
||||
|
||||
#### 4. Result Limiting
|
||||
|
||||
Results are sorted by final score and limited to the `maxResults` setting.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Out-of-Order Keywords (Issue #8)
|
||||
**Query:** "bar foo"
|
||||
|
||||
**Matches:**
|
||||
- ✅ `foo/bar/baz` - Both keywords present
|
||||
- ✅ `projects/foo/notes/bar` - Both keywords present
|
||||
- ❌ `foo/test` - Missing "bar" keyword
|
||||
- ❌ `bar/test` - Missing "foo" keyword
|
||||
|
||||
### Example 2: Parent-Child Prioritization (Issue #4)
|
||||
**Query:** "abc"
|
||||
|
||||
**Before (old behavior):**
|
||||
1. `abc` (score: 350)
|
||||
2. `abc/xxx` (score: 340)
|
||||
3. `abc/yyy` (score: 340)
|
||||
4. `abc/zzz` (score: 340)
|
||||
5. `projects/abc` (score: 150)
|
||||
|
||||
**After (new behavior):**
|
||||
1. `abc` (score: 350)
|
||||
2. `projects/abc` (score: 150)
|
||||
3. (Other non-related folders...)
|
||||
|
||||
Note: `abc/xxx`, `abc/yyy`, `abc/zzz` are completely excluded from results
|
||||
|
||||
### Example 3: Additional Keyword in Child
|
||||
**Query:** "abc test"
|
||||
|
||||
**Results:**
|
||||
1. `abc/test` (score: 450) - Matches both keywords, shown
|
||||
2. `abc` (score: 350) - Only matches "abc", shown
|
||||
3. `projects/abc/test` (score: 340) - Matches both, shown
|
||||
4. Other folders matching both keywords...
|
||||
|
||||
Note: `abc/test` is shown because it has 2 total keyword occurrences ("abc" + "test"), more than its parent `abc` which only has 1. However, `abc/notes` would be excluded because it still only has 1 occurrence of "abc", the same count as its parent.
|
||||
|
||||
### Example 4: Repeated Keywords
|
||||
**Query:** "ai prompt"
|
||||
|
||||
**Folder Structure:**
|
||||
- `AI/Prompt engineering` - Contains "ai" (1x) and "prompt" (1x) = 2 total occurrences
|
||||
- `AI/Prompt engineering/Prompt engineering` - Contains "ai" (1x) and "prompt" (2x) = 3 total occurrences
|
||||
- `AI/Prompt engineering/asset` - Contains "ai" (1x) and "prompt" (1x) = 2 total occurrences
|
||||
|
||||
**Results:**
|
||||
1. `AI/Prompt engineering` - Shown (2 occurrences)
|
||||
2. `AI/Prompt engineering/Prompt engineering` - Shown (3 occurrences, more than parent)
|
||||
3. `AI/Prompt engineering/asset` - Hidden (2 occurrences, same as parent)
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Type Safety
|
||||
- Added `FolderWithScore` interface for internal scoring
|
||||
- Maintains compatibility with Obsidian's `FuzzyMatch<TFolder>` type
|
||||
- Proper handling of separator items
|
||||
|
||||
### Debug Support
|
||||
- All search operations respect the `debugMode` setting
|
||||
- Logs query, keywords, and result counts when debug mode is enabled
|
||||
- Useful for troubleshooting search behavior
|
||||
|
||||
### Performance Considerations
|
||||
- Iterates through all folders once for scoring
|
||||
- Uses Set for efficient parent path lookups
|
||||
- Limits results to prevent UI overload
|
||||
- Complexity: O(n * k) where n = folders, k = keywords
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Potential enhancements for consideration:
|
||||
1. **Fuzzy character matching**: Allow typos and approximate matches
|
||||
2. **History-weighted scoring**: Boost scores for recently/frequently accessed folders
|
||||
3. **Custom weights**: Let users adjust scoring parameters
|
||||
4. **Regex support**: Enable advanced pattern matching
|
||||
5. **Path segment prioritization**: Give higher weight to matches in specific segments
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "folder-navigator",
|
||||
"name": "Folder Navigator",
|
||||
"version": "0.2.1",
|
||||
"version": "0.5.0",
|
||||
"minAppVersion": "1.5.3",
|
||||
"description": "Quickly navigate to folders in your vault using fuzzy search.",
|
||||
"author": "wenlzhang",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "plugin-template",
|
||||
"version": "0.2.1",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plugin-template",
|
||||
"version": "0.2.1",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "folder-navigator",
|
||||
"version": "0.2.1",
|
||||
"version": "0.5.0",
|
||||
"description": "Quickly navigate to folders in your vault using fuzzy search.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
import { App, FuzzySuggestModal, TFolder, WorkspaceLeaf, View } from "obsidian";
|
||||
import {
|
||||
App,
|
||||
FuzzySuggestModal,
|
||||
TFolder,
|
||||
WorkspaceLeaf,
|
||||
View,
|
||||
FuzzyMatch,
|
||||
Notice,
|
||||
} from "obsidian";
|
||||
import type FolderNavigatorPlugin from "./main";
|
||||
import { FolderDisplayMode } from "./settings";
|
||||
|
||||
// Helper function for conditional logging based on plugin settings
|
||||
function log(message: string, plugin: FolderNavigatorPlugin, ...args: any[]): void {
|
||||
function log(
|
||||
message: string,
|
||||
plugin: FolderNavigatorPlugin,
|
||||
...args: any[]
|
||||
): void {
|
||||
if (plugin.settings.debugMode) {
|
||||
console.log(message, ...args);
|
||||
}
|
||||
|
|
@ -15,7 +28,7 @@ function logError(message: string, ...args: any[]): void {
|
|||
|
||||
interface FileExplorerView extends View {
|
||||
fileItems: Record<string, any>;
|
||||
expandFolder?: (folder: TFolder) => void; // Optional: may not exist in all versions
|
||||
expandFolder?: (folder: TFolder) => void; // Optional: may not exist in all versions
|
||||
}
|
||||
|
||||
interface FileExplorerInstance {
|
||||
|
|
@ -35,16 +48,29 @@ interface ExtendedApp extends App {
|
|||
internalPlugins: InternalPlugins;
|
||||
}
|
||||
|
||||
interface FolderWithScore {
|
||||
folder: TFolder;
|
||||
score: number;
|
||||
match: FuzzyMatch<TFolder>;
|
||||
}
|
||||
|
||||
interface CreateFolderOption extends TFolder {
|
||||
_isCreateOption: boolean;
|
||||
_folderName: string;
|
||||
}
|
||||
|
||||
export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
|
||||
folders: TFolder[];
|
||||
plugin: FolderNavigatorPlugin;
|
||||
sortedFolders: TFolder[] = [];
|
||||
|
||||
constructor(app: App, plugin: FolderNavigatorPlugin) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.setPlaceholder("Type folder name...");
|
||||
this.folders = this.getAllFolders();
|
||||
|
||||
this.sortedFolders = this.getSortedFolders();
|
||||
|
||||
// Use the max results from settings
|
||||
this.limit = this.plugin.settings.maxResults;
|
||||
}
|
||||
|
|
@ -53,11 +79,436 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
|
|||
return this.app.vault.getAllFolders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort folders based on the selected display mode in settings
|
||||
*/
|
||||
private getSortedFolders(): TFolder[] {
|
||||
const allFolders = this.getAllFolders();
|
||||
const displayMode = this.plugin.settings.folderDisplayMode;
|
||||
|
||||
log(`Sorting folders using display mode: ${displayMode}`, this.plugin);
|
||||
|
||||
// Default sorting (alphabetical by path)
|
||||
if (displayMode === FolderDisplayMode.DEFAULT) {
|
||||
return allFolders;
|
||||
}
|
||||
|
||||
// Get folder history from settings
|
||||
const folderHistory = this.plugin.settings.folderHistory;
|
||||
|
||||
if (displayMode === FolderDisplayMode.RECENCY) {
|
||||
// Create a copy of all folders to sort
|
||||
const recentLimit = this.plugin.settings.recentFoldersToShow;
|
||||
|
||||
// Find folders with history and sort by last accessed timestamp (descending)
|
||||
const foldersWithHistory = allFolders.filter(
|
||||
(f) => folderHistory[f.path],
|
||||
);
|
||||
const recentFolders = foldersWithHistory
|
||||
.sort((a, b) => {
|
||||
const timeA = folderHistory[a.path]?.lastAccessed || 0;
|
||||
const timeB = folderHistory[b.path]?.lastAccessed || 0;
|
||||
return timeB - timeA; // Descending order (newest first)
|
||||
})
|
||||
.slice(0, recentLimit);
|
||||
|
||||
// Get the remaining folders (those without history or beyond the limit)
|
||||
const recentFolderPaths = new Set(recentFolders.map((f) => f.path));
|
||||
const remainingFolders = allFolders.filter(
|
||||
(f) => !recentFolderPaths.has(f.path),
|
||||
);
|
||||
|
||||
log(
|
||||
`Found ${recentFolders.length} recent folders to display`,
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// Only add separator if we have recent folders to show
|
||||
if (recentFolders.length > 0) {
|
||||
// Create a separator "folder" (not a real folder)
|
||||
const separator = {} as TFolder;
|
||||
(separator as any)._isSeparator = true;
|
||||
(separator as any)._separatorText =
|
||||
"— Recently visited folders —";
|
||||
|
||||
// Create another separator for the remaining folders
|
||||
const remainingSeparator = {} as TFolder;
|
||||
(remainingSeparator as any)._isSeparator = true;
|
||||
(remainingSeparator as any)._separatorText = "— All folders —";
|
||||
|
||||
// Combine recent folders with remaining folders with separators
|
||||
return [
|
||||
separator,
|
||||
...recentFolders,
|
||||
remainingSeparator,
|
||||
...remainingFolders,
|
||||
];
|
||||
}
|
||||
|
||||
// If no recent folders, just return all folders
|
||||
return allFolders;
|
||||
}
|
||||
|
||||
if (displayMode === FolderDisplayMode.FREQUENCY) {
|
||||
// Create a copy of all folders to sort
|
||||
const frequentLimit = this.plugin.settings.frequentFoldersToShow;
|
||||
|
||||
// Find folders with history and sort by access count (descending)
|
||||
const foldersWithHistory = allFolders.filter(
|
||||
(f) => folderHistory[f.path],
|
||||
);
|
||||
const frequentFolders = foldersWithHistory
|
||||
.sort((a, b) => {
|
||||
const countA = folderHistory[a.path]?.accessCount || 0;
|
||||
const countB = folderHistory[b.path]?.accessCount || 0;
|
||||
return countB - countA; // Descending order (most frequent first)
|
||||
})
|
||||
.slice(0, frequentLimit);
|
||||
|
||||
// Get the remaining folders (those without history or beyond the limit)
|
||||
const frequentFolderPaths = new Set(
|
||||
frequentFolders.map((f) => f.path),
|
||||
);
|
||||
const remainingFolders = allFolders.filter(
|
||||
(f) => !frequentFolderPaths.has(f.path),
|
||||
);
|
||||
|
||||
log(
|
||||
`Found ${frequentFolders.length} frequent folders to display`,
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// Only add separator if we have frequent folders to show
|
||||
if (frequentFolders.length > 0) {
|
||||
// Create a separator "folder" (not a real folder)
|
||||
const separator = {} as TFolder;
|
||||
(separator as any)._isSeparator = true;
|
||||
(separator as any)._separatorText =
|
||||
"— Frequently visited folders —";
|
||||
|
||||
// Create another separator for the remaining folders
|
||||
const remainingSeparator = {} as TFolder;
|
||||
(remainingSeparator as any)._isSeparator = true;
|
||||
(remainingSeparator as any)._separatorText = "— All folders —";
|
||||
|
||||
// Combine frequent folders with remaining folders with separators
|
||||
return [
|
||||
separator,
|
||||
...frequentFolders,
|
||||
remainingSeparator,
|
||||
...remainingFolders,
|
||||
];
|
||||
}
|
||||
|
||||
// If no frequent folders, just return all folders
|
||||
return allFolders;
|
||||
}
|
||||
|
||||
// Fallback to default sorting
|
||||
return allFolders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update folder access history when a folder is selected
|
||||
*/
|
||||
private updateFolderHistory(folder: TFolder): void {
|
||||
// Get current history or create new entry
|
||||
const folderPath = folder.path;
|
||||
const history = this.plugin.settings.folderHistory[folderPath] || {
|
||||
lastAccessed: 0,
|
||||
accessCount: 0,
|
||||
};
|
||||
|
||||
// Update history data
|
||||
history.lastAccessed = Date.now();
|
||||
history.accessCount += 1;
|
||||
|
||||
// Save back to settings
|
||||
this.plugin.settings.folderHistory[folderPath] = history;
|
||||
|
||||
// Save settings
|
||||
this.plugin.saveSettings();
|
||||
|
||||
log(
|
||||
`Updated folder history for "${folderPath}": ${JSON.stringify(history)}`,
|
||||
this.plugin,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom search implementation with improved fuzzy matching
|
||||
* Supports out-of-order keyword matching and better scoring
|
||||
*/
|
||||
getSuggestions(query: string): FuzzyMatch<TFolder>[] {
|
||||
// If no query, return all items (or recent/frequent items based on settings)
|
||||
if (!query || query.trim().length === 0) {
|
||||
return this.sortedFolders.map((folder) => ({
|
||||
item: folder,
|
||||
match: { score: 0, matches: [] },
|
||||
}));
|
||||
}
|
||||
|
||||
const normalizedQuery = query.toLowerCase().trim();
|
||||
|
||||
// Split query into keywords (space-separated)
|
||||
const keywords = normalizedQuery
|
||||
.split(/\s+/)
|
||||
.filter((k) => k.length > 0);
|
||||
|
||||
log(
|
||||
`Search query: "${query}", keywords: [${keywords.join(", ")}]`,
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// Score each folder
|
||||
const foldersWithScore: FolderWithScore[] = [];
|
||||
|
||||
for (const folder of this.sortedFolders) {
|
||||
// Skip separators
|
||||
if ((folder as any)._isSeparator) {
|
||||
foldersWithScore.push({
|
||||
folder,
|
||||
score: -1,
|
||||
match: { item: folder, match: { score: 0, matches: [] } },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderPath = folder.path.toLowerCase();
|
||||
const folderName = folder.name.toLowerCase();
|
||||
const originalPath = folder.path; // Keep original case for match positions
|
||||
|
||||
// Check if all keywords match (in any order)
|
||||
let allKeywordsMatch = true;
|
||||
let totalScore = 0;
|
||||
const matchPositions: Array<[number, number]> = [];
|
||||
|
||||
for (const keyword of keywords) {
|
||||
const pathIndex = folderPath.indexOf(keyword);
|
||||
const nameIndex = folderName.indexOf(keyword);
|
||||
|
||||
if (pathIndex === -1) {
|
||||
// Keyword doesn't match this folder at all
|
||||
allKeywordsMatch = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Store match positions for highlighting (using original path positions)
|
||||
// Find ALL occurrences of this keyword in the original case-sensitive path
|
||||
const originalPathLower = originalPath.toLowerCase();
|
||||
let pos = 0;
|
||||
while ((pos = originalPathLower.indexOf(keyword, pos)) !== -1) {
|
||||
// Record character positions for this occurrence
|
||||
for (let i = 0; i < keyword.length; i++) {
|
||||
matchPositions.push([pos + i, pos + i]);
|
||||
}
|
||||
pos += keyword.length; // Move past this occurrence to find the next one
|
||||
}
|
||||
|
||||
// Calculate score for this keyword
|
||||
let keywordScore = 0;
|
||||
|
||||
// Bonus for matching in folder name (not just path)
|
||||
if (nameIndex !== -1) {
|
||||
keywordScore += 100;
|
||||
|
||||
// Extra bonus for exact name match
|
||||
if (folderName === keyword) {
|
||||
keywordScore += 200;
|
||||
}
|
||||
// Bonus for name starting with keyword
|
||||
else if (nameIndex === 0) {
|
||||
keywordScore += 50;
|
||||
}
|
||||
} else {
|
||||
// Match is in parent path, lower score
|
||||
keywordScore += 10;
|
||||
}
|
||||
|
||||
// Bonus for match position (earlier is better)
|
||||
keywordScore += Math.max(0, 50 - pathIndex);
|
||||
|
||||
totalScore += keywordScore;
|
||||
}
|
||||
|
||||
if (allKeywordsMatch) {
|
||||
// Additional scoring adjustments
|
||||
|
||||
// Penalize deeper nested folders
|
||||
const depth = folder.path.split("/").length;
|
||||
totalScore -= depth * 5;
|
||||
|
||||
// Bonus for shorter paths (more specific)
|
||||
totalScore += Math.max(0, 100 - folder.path.length);
|
||||
|
||||
foldersWithScore.push({
|
||||
folder,
|
||||
score: totalScore,
|
||||
match: {
|
||||
item: folder,
|
||||
match: {
|
||||
score: totalScore,
|
||||
matches: matchPositions,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (descending)
|
||||
foldersWithScore.sort((a, b) => {
|
||||
// Keep separators at their original positions
|
||||
if (
|
||||
(a.folder as any)._isSeparator ||
|
||||
(b.folder as any)._isSeparator
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
// Apply parent folder deduplication for Issue #4
|
||||
// When a parent folder matches, completely exclude its children unless they have additional keyword matches
|
||||
const results: FuzzyMatch<TFolder>[] = [];
|
||||
const parentPaths = new Set<string>();
|
||||
|
||||
for (const item of foldersWithScore) {
|
||||
// Always include separators
|
||||
if ((item.folder as any)._isSeparator) {
|
||||
results.push(item.match);
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderPath = item.folder.path;
|
||||
|
||||
// Check if this folder's parent is already in results
|
||||
let shouldExclude = false;
|
||||
let isChildOfMatch = false;
|
||||
|
||||
for (const parentPath of parentPaths) {
|
||||
// Check if current folder is a child of an already matched parent
|
||||
if (folderPath.startsWith(parentPath + "/")) {
|
||||
isChildOfMatch = true;
|
||||
|
||||
// Check if this child has additional keyword occurrences beyond the parent
|
||||
// Count total occurrences of all keywords in both parent and child
|
||||
const parentLower = parentPath.toLowerCase();
|
||||
const childLower = folderPath.toLowerCase();
|
||||
|
||||
let parentOccurrences = 0;
|
||||
let childOccurrences = 0;
|
||||
|
||||
for (const keyword of keywords) {
|
||||
// Count occurrences of this keyword in parent
|
||||
let pos = 0;
|
||||
while (
|
||||
(pos = parentLower.indexOf(keyword, pos)) !== -1
|
||||
) {
|
||||
parentOccurrences++;
|
||||
pos += keyword.length;
|
||||
}
|
||||
|
||||
// Count occurrences of this keyword in child
|
||||
pos = 0;
|
||||
while (
|
||||
(pos = childLower.indexOf(keyword, pos)) !== -1
|
||||
) {
|
||||
childOccurrences++;
|
||||
pos += keyword.length;
|
||||
}
|
||||
}
|
||||
|
||||
// If child doesn't have more keyword occurrences than parent, exclude it
|
||||
if (childOccurrences <= parentOccurrences) {
|
||||
shouldExclude = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only include if not excluded
|
||||
if (!shouldExclude) {
|
||||
results.push(item.match);
|
||||
|
||||
// Add this folder to parent paths for future checks
|
||||
if (!isChildOfMatch) {
|
||||
parentPaths.add(folderPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit results
|
||||
const limitedResults = results.slice(0, this.limit);
|
||||
|
||||
log(
|
||||
`Found ${limitedResults.length} matching folders (filtered from ${foldersWithScore.length} total matches)`,
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// If query is provided, add "Create new folder" option at the end
|
||||
if (normalizedQuery.length > 0) {
|
||||
// Filter out separators to count real folder matches
|
||||
const realFolderMatches = limitedResults.filter(
|
||||
(result) => !(result.item as any)._isSeparator,
|
||||
);
|
||||
|
||||
// Only show create option if the exact folder doesn't already exist
|
||||
const exactMatch = realFolderMatches.some(
|
||||
(result) =>
|
||||
result.item.path.toLowerCase() === normalizedQuery ||
|
||||
result.item.name.toLowerCase() === normalizedQuery,
|
||||
);
|
||||
|
||||
if (!exactMatch) {
|
||||
const createOption = {} as CreateFolderOption;
|
||||
createOption._isCreateOption = true;
|
||||
createOption._folderName = query.trim();
|
||||
|
||||
// Add separator before create option if there are results
|
||||
if (limitedResults.length > 0) {
|
||||
const separator = {} as TFolder;
|
||||
(separator as any)._isSeparator = true;
|
||||
(separator as any)._separatorText = "—————";
|
||||
|
||||
return [
|
||||
...limitedResults,
|
||||
{
|
||||
item: separator,
|
||||
match: { score: 0, matches: [] },
|
||||
},
|
||||
{
|
||||
item: createOption as TFolder,
|
||||
match: { score: 0, matches: [] },
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// No results, just show create option
|
||||
return [
|
||||
{
|
||||
item: createOption as TFolder,
|
||||
match: { score: 0, matches: [] },
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return limitedResults;
|
||||
}
|
||||
|
||||
getItems(): TFolder[] {
|
||||
return this.folders;
|
||||
return this.sortedFolders;
|
||||
}
|
||||
|
||||
getItemText(folder: TFolder): string {
|
||||
// For separator folders (which will have a special property), return the separator text
|
||||
if ((folder as any)._isSeparator) {
|
||||
return (folder as any)._separatorText;
|
||||
}
|
||||
// For create folder option
|
||||
if ((folder as CreateFolderOption)._isCreateOption) {
|
||||
return `Create new folder: ${(folder as CreateFolderOption)._folderName}`;
|
||||
}
|
||||
return folder.path;
|
||||
}
|
||||
|
||||
|
|
@ -67,12 +518,12 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
|
|||
private getParentFolderChain(folder: TFolder): TFolder[] {
|
||||
const parentChain: TFolder[] = [];
|
||||
let currentFolder: TFolder | null = folder.parent;
|
||||
|
||||
|
||||
while (currentFolder) {
|
||||
parentChain.unshift(currentFolder); // Add at beginning to get root->leaf order
|
||||
currentFolder = currentFolder.parent;
|
||||
}
|
||||
|
||||
|
||||
return parentChain;
|
||||
}
|
||||
|
||||
|
|
@ -82,28 +533,35 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
|
|||
private getFileExplorer(): FileExplorerInstance | null {
|
||||
try {
|
||||
const app = this.app as ExtendedApp;
|
||||
|
||||
|
||||
if (!app.internalPlugins?.plugins["file-explorer"]?.instance) {
|
||||
logError("Could not find file explorer plugin instance");
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileExplorer = app.internalPlugins.plugins["file-explorer"].instance;
|
||||
|
||||
const fileExplorer =
|
||||
app.internalPlugins.plugins["file-explorer"].instance;
|
||||
|
||||
if (typeof fileExplorer.revealInFolder !== "function") {
|
||||
logError("revealInFolder method not found on file explorer instance");
|
||||
logError(
|
||||
"revealInFolder method not found on file explorer instance",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Log available methods for debugging
|
||||
if (this.plugin.settings.debugMode) {
|
||||
log("File explorer instance methods:", this.plugin);
|
||||
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(fileExplorer));
|
||||
const methods = Object.getOwnPropertyNames(
|
||||
Object.getPrototypeOf(fileExplorer),
|
||||
);
|
||||
log(methods.join(", "), this.plugin);
|
||||
|
||||
|
||||
if (fileExplorer.view) {
|
||||
log("File explorer view methods:", this.plugin);
|
||||
const viewMethods = Object.getOwnPropertyNames(Object.getPrototypeOf(fileExplorer.view));
|
||||
const viewMethods = Object.getOwnPropertyNames(
|
||||
Object.getPrototypeOf(fileExplorer.view),
|
||||
);
|
||||
log(viewMethods.join(", "), this.plugin);
|
||||
}
|
||||
}
|
||||
|
|
@ -116,45 +574,92 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Event handler for when a folder is chosen from the suggestion list
|
||||
* Handle creating a new folder
|
||||
*/
|
||||
onChooseItem(folder: TFolder): void {
|
||||
log(`Folder selected: "${folder.path}" (name: "${folder.name}")`, this.plugin);
|
||||
|
||||
private async handleCreateFolder(folderName: string): Promise<void> {
|
||||
try {
|
||||
// Close the modal first
|
||||
log(`Creating new folder: "${folderName}"`, this.plugin);
|
||||
|
||||
// Get the base path from settings
|
||||
const basePath = this.plugin.settings.newFolderLocation;
|
||||
const fullPath = basePath
|
||||
? `${basePath}/${folderName}`
|
||||
: folderName;
|
||||
|
||||
log(`Full path for new folder: "${fullPath}"`, this.plugin);
|
||||
|
||||
// Check if folder already exists
|
||||
const existingFolder =
|
||||
this.app.vault.getAbstractFileByPath(fullPath);
|
||||
if (existingFolder) {
|
||||
new Notice(`Folder "${fullPath}" already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the folder
|
||||
const newFolder = await this.app.vault.createFolder(fullPath);
|
||||
log(`Folder created successfully: "${fullPath}"`, this.plugin);
|
||||
|
||||
new Notice(`Folder "${fullPath}" created successfully`);
|
||||
|
||||
// Close the modal
|
||||
this.close();
|
||||
|
||||
|
||||
// Navigate to the newly created folder
|
||||
setTimeout(() => {
|
||||
this.navigateToFolder(newFolder);
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
logError(`Error creating folder: ${error}`);
|
||||
new Notice(`Failed to create folder: ${error.message || error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a folder (same logic as in onChooseItem)
|
||||
*/
|
||||
private navigateToFolder(folder: TFolder): void {
|
||||
try {
|
||||
// Update folder history
|
||||
this.updateFolderHistory(folder);
|
||||
|
||||
// Make sure the file explorer is visible
|
||||
const fileExplorerLeaves = this.app.workspace.getLeavesOfType("file-explorer");
|
||||
const fileExplorerLeaves =
|
||||
this.app.workspace.getLeavesOfType("file-explorer");
|
||||
if (fileExplorerLeaves.length === 0) {
|
||||
logError("No file explorer leaf found");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const fileExplorerLeaf = fileExplorerLeaves[0];
|
||||
this.app.workspace.revealLeaf(fileExplorerLeaf);
|
||||
log("File explorer leaf revealed", this.plugin);
|
||||
|
||||
|
||||
// Get the file explorer instance
|
||||
const fileExplorer = this.getFileExplorer();
|
||||
if (!fileExplorer) {
|
||||
logError("Failed to get file explorer instance");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Step 1: Reveal the folder in the file explorer
|
||||
log(`Revealing folder in explorer: "${folder.path}"`, this.plugin);
|
||||
fileExplorer.revealInFolder(folder);
|
||||
|
||||
|
||||
// Step 2: Wait a bit for the UI to update, then expand folders using DOM
|
||||
setTimeout(() => {
|
||||
if (this.plugin.settings.expandTargetFolder) {
|
||||
// Get all parent folders in order
|
||||
log(`Expanding parent folders for: "${folder.path}"`, this.plugin);
|
||||
log(
|
||||
`Expanding parent folders for: "${folder.path}"`,
|
||||
this.plugin,
|
||||
);
|
||||
const parentFolders = this.getParentFolderChain(folder);
|
||||
log(`Parent chain: ${parentFolders.map(f => f.name).join(" -> ")}`, this.plugin);
|
||||
|
||||
log(
|
||||
`Parent chain: ${parentFolders.map((f) => f.name).join(" -> ")}`,
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// First, expand all parent folders in order from root to leaf
|
||||
parentFolders.forEach((parentFolder, index) => {
|
||||
// Use increasing delays to ensure proper sequence
|
||||
|
|
@ -162,17 +667,23 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
|
|||
this.expandFolderByDOMClick(parentFolder.name);
|
||||
}, index * 50);
|
||||
});
|
||||
|
||||
|
||||
// Then expand the target folder itself (after parents are expanded)
|
||||
setTimeout(() => {
|
||||
log(`Expanding target folder: "${folder.name}"`, this.plugin);
|
||||
this.expandFolderByDOMClick(folder.name);
|
||||
|
||||
// Highlight the folder after expansion
|
||||
setTimeout(() => {
|
||||
this.highlightFolderInDOM(folder.name);
|
||||
}, 200);
|
||||
}, parentFolders.length * 50 + 100);
|
||||
setTimeout(
|
||||
() => {
|
||||
log(
|
||||
`Expanding target folder: "${folder.name}"`,
|
||||
this.plugin,
|
||||
);
|
||||
this.expandFolderByDOMClick(folder.name);
|
||||
|
||||
// Highlight the folder after expansion
|
||||
setTimeout(() => {
|
||||
this.highlightFolderInDOM(folder.name);
|
||||
}, 200);
|
||||
},
|
||||
parentFolders.length * 50 + 100,
|
||||
);
|
||||
} else {
|
||||
// Just highlight the folder if we're not expanding
|
||||
setTimeout(() => {
|
||||
|
|
@ -184,95 +695,232 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
|
|||
logError(`Error in folder navigation: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Event handler for when a folder is chosen from the suggestion list
|
||||
*/
|
||||
onChooseItem(folder: TFolder, evt: MouseEvent | KeyboardEvent): void {
|
||||
// Check if this is a separator and ignore if so
|
||||
if ((folder as any)._isSeparator) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a create folder option
|
||||
if ((folder as CreateFolderOption)._isCreateOption) {
|
||||
this.handleCreateFolder((folder as CreateFolderOption)._folderName);
|
||||
return;
|
||||
}
|
||||
|
||||
log(
|
||||
`Folder selected: "${folder.path}" (name: "${folder.name}")`,
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// Close the modal first
|
||||
this.close();
|
||||
|
||||
// Navigate to the selected folder
|
||||
setTimeout(() => {
|
||||
this.navigateToFolder(folder);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a folder by finding and clicking its title directly in the DOM
|
||||
*/
|
||||
private expandFolderByDOMClick(folderName: string): void {
|
||||
try {
|
||||
log(`Looking for folder to expand: "${folderName}"`, this.plugin);
|
||||
|
||||
|
||||
// Find all folder title content elements
|
||||
const allTitleContents = document.querySelectorAll(".nav-folder-title-content");
|
||||
|
||||
const allTitleContents = document.querySelectorAll(
|
||||
".nav-folder-title-content",
|
||||
);
|
||||
|
||||
for (const titleContent of Array.from(allTitleContents)) {
|
||||
if (titleContent.textContent === folderName) {
|
||||
log(`Found folder title content: "${folderName}"`, this.plugin);
|
||||
|
||||
log(
|
||||
`Found folder title content: "${folderName}"`,
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// Get the title element
|
||||
const titleElement = titleContent.closest(".nav-folder-title");
|
||||
const titleElement =
|
||||
titleContent.closest(".nav-folder-title");
|
||||
if (!(titleElement instanceof HTMLElement)) {
|
||||
log(`Could not find title element for: "${folderName}"`, this.plugin);
|
||||
log(
|
||||
`Could not find title element for: "${folderName}"`,
|
||||
this.plugin,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Get the folder element
|
||||
const folderElement = titleContent.closest(".nav-folder");
|
||||
if (!(folderElement instanceof HTMLElement)) {
|
||||
log(`Could not find folder element for: "${folderName}"`, this.plugin);
|
||||
log(
|
||||
`Could not find folder element for: "${folderName}"`,
|
||||
this.plugin,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Check if it's collapsed
|
||||
if (folderElement.classList.contains("is-collapsed")) {
|
||||
log(`Folder is collapsed, clicking title to expand: "${folderName}"`, this.plugin);
|
||||
log(
|
||||
`Folder is collapsed, clicking title to expand: "${folderName}"`,
|
||||
this.plugin,
|
||||
);
|
||||
// Click the title element directly - this is more reliable than the collapse indicator
|
||||
titleElement.click();
|
||||
return;
|
||||
} else {
|
||||
log(`Folder already expanded: "${folderName}"`, this.plugin);
|
||||
log(
|
||||
`Folder already expanded: "${folderName}"`,
|
||||
this.plugin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(`Could not find folder with name: "${folderName}"`, this.plugin);
|
||||
|
||||
log(
|
||||
`Could not find folder with name: "${folderName}"`,
|
||||
this.plugin,
|
||||
);
|
||||
} catch (error) {
|
||||
logError(`Error expanding folder: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Highlight a folder in the DOM by finding and styling its title
|
||||
*/
|
||||
private highlightFolderInDOM(folderName: string): void {
|
||||
try {
|
||||
log(`Looking for folder to highlight: "${folderName}"`, this.plugin);
|
||||
|
||||
log(
|
||||
`Looking for folder to highlight: "${folderName}"`,
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// Find all folder title content elements
|
||||
const allTitleContents = document.querySelectorAll(".nav-folder-title-content");
|
||||
|
||||
const allTitleContents = document.querySelectorAll(
|
||||
".nav-folder-title-content",
|
||||
);
|
||||
|
||||
for (const titleContent of Array.from(allTitleContents)) {
|
||||
if (titleContent.textContent === folderName) {
|
||||
log(`Found folder to highlight: "${folderName}"`, this.plugin);
|
||||
|
||||
log(
|
||||
`Found folder to highlight: "${folderName}"`,
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// Get the title element
|
||||
const titleElement = titleContent.closest(".nav-folder-title");
|
||||
const titleElement =
|
||||
titleContent.closest(".nav-folder-title");
|
||||
if (!(titleElement instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Scroll into view
|
||||
titleElement.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
|
||||
|
||||
// Add highlighting class
|
||||
titleElement.classList.add("nav-folder-title-highlighted");
|
||||
|
||||
|
||||
// Remove after delay
|
||||
setTimeout(() => {
|
||||
titleElement.classList.remove("nav-folder-title-highlighted");
|
||||
titleElement.classList.remove(
|
||||
"nav-folder-title-highlighted",
|
||||
);
|
||||
}, 2000);
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log(`Could not find folder to highlight: "${folderName}"`, this.plugin);
|
||||
|
||||
log(
|
||||
`Could not find folder to highlight: "${folderName}"`,
|
||||
this.plugin,
|
||||
);
|
||||
} catch (error) {
|
||||
logError(`Error highlighting folder: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Override the renderSuggestion method to customize how folders are displayed
|
||||
renderSuggestion(item: FuzzyMatch<TFolder>, el: HTMLElement): void {
|
||||
// Check if this is a separator
|
||||
if ((item.item as any)._isSeparator) {
|
||||
el.empty();
|
||||
const separatorEl = el.createDiv({
|
||||
cls: "folder-separator",
|
||||
});
|
||||
separatorEl.setText((item.item as any)._separatorText);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a create folder option
|
||||
if ((item.item as CreateFolderOption)._isCreateOption) {
|
||||
el.empty();
|
||||
const createEl = el.createDiv({
|
||||
cls: "folder-create-option",
|
||||
});
|
||||
const iconEl = createEl.createSpan({
|
||||
cls: "folder-create-icon",
|
||||
});
|
||||
iconEl.setText("➕ ");
|
||||
const textEl = createEl.createSpan({
|
||||
cls: "folder-create-text",
|
||||
});
|
||||
textEl.setText(
|
||||
`Create new folder: "${(item.item as CreateFolderOption)._folderName}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom rendering with keyword highlighting
|
||||
el.empty();
|
||||
const folder = item.item;
|
||||
const folderPath = folder.path;
|
||||
|
||||
// Get match positions from the fuzzy match result
|
||||
const matches = item.match?.matches || [];
|
||||
|
||||
if (matches.length === 0) {
|
||||
// No matches, just display the text normally
|
||||
el.setText(folderPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort matches by position
|
||||
const sortedMatches = matches.slice().sort((a, b) => a[0] - b[0]);
|
||||
|
||||
// Build the highlighted text
|
||||
let lastIndex = 0;
|
||||
const textEl = el.createDiv({ cls: "suggestion-content" });
|
||||
|
||||
for (const match of sortedMatches) {
|
||||
const [start, end] = match;
|
||||
|
||||
// Add text before the match
|
||||
if (start > lastIndex) {
|
||||
textEl.appendText(folderPath.substring(lastIndex, start));
|
||||
}
|
||||
|
||||
// Add the matched text with highlighting
|
||||
const matchEl = textEl.createSpan({ cls: "suggestion-highlight" });
|
||||
matchEl.setText(folderPath.substring(start, end + 1));
|
||||
|
||||
lastIndex = end + 1;
|
||||
}
|
||||
|
||||
// Add remaining text after the last match
|
||||
if (lastIndex < folderPath.length) {
|
||||
textEl.appendText(folderPath.substring(lastIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,35 @@
|
|||
import { App } from "obsidian";
|
||||
|
||||
export enum FolderDisplayMode {
|
||||
DEFAULT = "default",
|
||||
RECENCY = "recency",
|
||||
FREQUENCY = "frequency",
|
||||
}
|
||||
|
||||
export interface PluginSettings {
|
||||
maxResults: number;
|
||||
expandTargetFolder: boolean;
|
||||
debugMode: boolean;
|
||||
folderDisplayMode: FolderDisplayMode;
|
||||
recentFoldersToShow: number;
|
||||
frequentFoldersToShow: number;
|
||||
folderHistory: Record<
|
||||
string,
|
||||
{
|
||||
lastAccessed: number; // timestamp
|
||||
accessCount: number; // visit count
|
||||
}
|
||||
>;
|
||||
newFolderLocation: string; // Path where new folders should be created (empty string = root)
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
maxResults: 10,
|
||||
expandTargetFolder: true, // Enable by default for better navigation
|
||||
debugMode: false, // Disabled by default
|
||||
folderDisplayMode: FolderDisplayMode.DEFAULT,
|
||||
recentFoldersToShow: 5,
|
||||
frequentFoldersToShow: 5,
|
||||
folderHistory: {},
|
||||
newFolderLocation: "", // Empty string means root folder
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,18 +1,171 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import {
|
||||
App,
|
||||
PluginSettingTab,
|
||||
Setting,
|
||||
DropdownComponent,
|
||||
Modal,
|
||||
Notice,
|
||||
FuzzySuggestModal,
|
||||
TFolder,
|
||||
} from "obsidian";
|
||||
import FolderNavigatorPlugin from "./main";
|
||||
import { FolderDisplayMode } from "./settings";
|
||||
|
||||
/**
|
||||
* Modal for searching and selecting a folder for new folder creation location
|
||||
*/
|
||||
class FolderSelectionModal extends FuzzySuggestModal<TFolder> {
|
||||
plugin: FolderNavigatorPlugin;
|
||||
onSelect: (folder: TFolder) => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: FolderNavigatorPlugin,
|
||||
onSelect: (folder: TFolder) => void,
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.onSelect = onSelect;
|
||||
this.setPlaceholder("Search for a folder...");
|
||||
}
|
||||
|
||||
getItems(): TFolder[] {
|
||||
return this.app.vault.getAllFolders();
|
||||
}
|
||||
|
||||
getItemText(folder: TFolder): string {
|
||||
return folder.path || "/";
|
||||
}
|
||||
|
||||
onChooseItem(folder: TFolder): void {
|
||||
this.onSelect(folder);
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsTab extends PluginSettingTab {
|
||||
plugin: FolderNavigatorPlugin;
|
||||
|
||||
// Keep track of settings elements for dynamic updates
|
||||
recentFoldersSettingEl: HTMLElement;
|
||||
frequentFoldersSettingEl: HTMLElement;
|
||||
resetHistorySettingEl: HTMLElement;
|
||||
|
||||
constructor(app: App, plugin: FolderNavigatorPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates visibility of settings based on the current display mode
|
||||
*/
|
||||
updateSettingsVisibility(displayMode: FolderDisplayMode): void {
|
||||
// Create settings elements if they don't exist yet
|
||||
if (
|
||||
!this.recentFoldersSettingEl ||
|
||||
!this.frequentFoldersSettingEl ||
|
||||
!this.resetHistorySettingEl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle recent folders setting
|
||||
if (displayMode === FolderDisplayMode.RECENCY) {
|
||||
this.recentFoldersSettingEl.style.display = "block";
|
||||
this.frequentFoldersSettingEl.style.display = "none";
|
||||
} else if (displayMode === FolderDisplayMode.FREQUENCY) {
|
||||
this.recentFoldersSettingEl.style.display = "none";
|
||||
this.frequentFoldersSettingEl.style.display = "block";
|
||||
} else {
|
||||
// Default mode - hide both
|
||||
this.recentFoldersSettingEl.style.display = "none";
|
||||
this.frequentFoldersSettingEl.style.display = "none";
|
||||
}
|
||||
|
||||
// Only show reset history button for non-default modes
|
||||
if (displayMode === FolderDisplayMode.DEFAULT) {
|
||||
this.resetHistorySettingEl.style.display = "none";
|
||||
} else {
|
||||
this.resetHistorySettingEl.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
// Folder display
|
||||
new Setting(containerEl).setName("Folder display").setHeading();
|
||||
|
||||
// Display priority
|
||||
new Setting(containerEl)
|
||||
.setName("Display priority")
|
||||
.setDesc(
|
||||
"Control which folders appear at the top of the suggestion list before searching",
|
||||
)
|
||||
.addDropdown((dropdown: DropdownComponent) => {
|
||||
dropdown
|
||||
.addOption(
|
||||
FolderDisplayMode.DEFAULT,
|
||||
"Default order (alphabetical)",
|
||||
)
|
||||
.addOption(
|
||||
FolderDisplayMode.RECENCY,
|
||||
"Recently visited first",
|
||||
)
|
||||
.addOption(
|
||||
FolderDisplayMode.FREQUENCY,
|
||||
"Frequently visited first",
|
||||
)
|
||||
.setValue(this.plugin.settings.folderDisplayMode)
|
||||
.onChange(async (value: string) => {
|
||||
this.plugin.settings.folderDisplayMode =
|
||||
value as FolderDisplayMode;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Update the visibility of settings when selection changes
|
||||
this.updateSettingsVisibility(
|
||||
value as FolderDisplayMode,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Recent folders
|
||||
this.recentFoldersSettingEl = containerEl.createDiv();
|
||||
new Setting(this.recentFoldersSettingEl)
|
||||
.setName("Recent folders to show")
|
||||
.setDesc(
|
||||
"Number of recently visited folders to display at the top when using 'Recently visited first' mode",
|
||||
)
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(1, 20, 1)
|
||||
.setValue(this.plugin.settings.recentFoldersToShow)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.recentFoldersToShow = value;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
// Frequent folders
|
||||
this.frequentFoldersSettingEl = containerEl.createDiv();
|
||||
new Setting(this.frequentFoldersSettingEl)
|
||||
.setName("Frequent folders to show")
|
||||
.setDesc(
|
||||
"Number of frequently visited folders to display at the top when using 'Frequently visited first' mode",
|
||||
)
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(1, 20, 1)
|
||||
.setValue(this.plugin.settings.frequentFoldersToShow)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.frequentFoldersToShow = value;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
// Maximum results
|
||||
new Setting(containerEl)
|
||||
.setName("Maximum results")
|
||||
.setDesc("Maximum number of folders to show in search results")
|
||||
|
|
@ -27,6 +180,99 @@ export class SettingsTab extends PluginSettingTab {
|
|||
}),
|
||||
);
|
||||
|
||||
// Reset history
|
||||
this.resetHistorySettingEl = containerEl.createDiv();
|
||||
new Setting(this.resetHistorySettingEl)
|
||||
.setName("Reset folder history")
|
||||
.setDesc(
|
||||
"Clear the tracked history of visited folders. Warning: This will remove all recency and frequency data.",
|
||||
)
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Reset")
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
// Create a confirmation modal
|
||||
const confirmModal = new Modal(this.app);
|
||||
confirmModal.titleEl.setText("Reset folder history");
|
||||
confirmModal.contentEl.createEl("p", {
|
||||
text: "Are you sure? This will permanently delete all folder navigation history including recently visited and frequently used folder data.",
|
||||
cls: "mod-warning",
|
||||
});
|
||||
|
||||
const buttonContainer =
|
||||
confirmModal.contentEl.createDiv({
|
||||
cls: "modal-button-container",
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
buttonContainer
|
||||
.createEl("button", {
|
||||
text: "Cancel",
|
||||
})
|
||||
.addEventListener("click", () => {
|
||||
confirmModal.close();
|
||||
});
|
||||
|
||||
// Confirm button
|
||||
const confirmButton = buttonContainer.createEl(
|
||||
"button",
|
||||
{
|
||||
text: "Reset folder history",
|
||||
cls: "mod-warning",
|
||||
},
|
||||
);
|
||||
confirmButton.addEventListener("click", async () => {
|
||||
this.plugin.settings.folderHistory = {};
|
||||
await this.plugin.saveSettings();
|
||||
confirmModal.close();
|
||||
new Notice("Folder history has been reset");
|
||||
});
|
||||
|
||||
confirmModal.open();
|
||||
}),
|
||||
);
|
||||
|
||||
// New folder location
|
||||
new Setting(containerEl).setName("Folder creation").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Default location for new folders")
|
||||
.setDesc(
|
||||
"Choose where new folders will be created when using the 'Create folder' option. Default is the vault root.",
|
||||
)
|
||||
.addButton((button) => {
|
||||
const displayPath =
|
||||
this.plugin.settings.newFolderLocation || "/ (Root)";
|
||||
button.setButtonText(displayPath).onClick(() => {
|
||||
const modal = new FolderSelectionModal(
|
||||
this.app,
|
||||
this.plugin,
|
||||
async (folder: TFolder) => {
|
||||
this.plugin.settings.newFolderLocation =
|
||||
folder.path;
|
||||
await this.plugin.saveSettings();
|
||||
button.setButtonText(folder.path || "/ (Root)");
|
||||
},
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
})
|
||||
.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("reset")
|
||||
.setTooltip("Reset to root folder")
|
||||
.onClick(async () => {
|
||||
this.plugin.settings.newFolderLocation = "";
|
||||
await this.plugin.saveSettings();
|
||||
// Update button text by recreating the setting
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
// Folder navigation
|
||||
new Setting(containerEl).setName("Folder navigation").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Expand target folder on navigation")
|
||||
.setDesc(
|
||||
|
|
@ -40,10 +286,13 @@ export class SettingsTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
// Initialize settings visibility based on current display mode
|
||||
this.updateSettingsVisibility(this.plugin.settings.folderDisplayMode);
|
||||
|
||||
// Add advanced section
|
||||
new Setting(containerEl).setName("Advanced").setHeading();
|
||||
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Debug mode")
|
||||
.setDesc(
|
||||
|
|
|
|||
31
styles.css
31
styles.css
|
|
@ -3,3 +3,34 @@
|
|||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.folder-separator {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
padding: 5px 0;
|
||||
font-style: italic;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
margin-bottom: 5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.suggestion-highlight {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.folder-create-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.folder-create-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.folder-create-text {
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,10 @@
|
|||
"0.0.1": "0.0.1",
|
||||
"0.1.0": "1.0.0",
|
||||
"0.2.0": "1.5.3",
|
||||
"0.2.1": "1.5.3"
|
||||
"0.2.1": "1.5.3",
|
||||
"0.3.0": "1.5.3",
|
||||
"0.3.1": "1.5.3",
|
||||
"0.4.0": "1.5.3",
|
||||
"0.4.1": "1.5.3",
|
||||
"0.5.0": "1.5.3"
|
||||
}
|
||||
Loading…
Reference in a new issue