refactor: consolidate file selector modals into FileSelectorModal

Create a new unified FileSelectorModal that replaces AttachmentSelectModal
and ICSNoteLinkModal. The new modal provides:
- Fuzzy search for existing files
- Shift+Enter to create new files with typed name
- Configurable filter (markdown only, all files, or custom)
- Simple footer showing create option with filename preview

Updated usages:
- TimeblockCreationModal: attachments (filter: all)
- TimeblockInfoModal: attachments (filter: all)
- ICSEventContextMenu: note linking (filter: markdown)
- ICSEventInfoModal: note linking (filter: markdown)

Also improved task selector create preview spacing.

Removed:
- src/modals/AttachmentSelectModal.ts
- src/modals/ICSNoteLinkModal.ts
This commit is contained in:
callumalpass 2025-11-27 22:52:49 +11:00
parent bd632ea401
commit f03ddbce58
10 changed files with 504 additions and 231 deletions

View file

@ -19,6 +19,7 @@ const CSS_FILES = [
'styles/reminder-modal.css', // Reminder modal component with proper BEM scoping
'styles/date-picker.css', // Enhanced date/time picker styling
'styles/task-selector-with-create-modal.css', // TaskSelectorWithCreateModal component with proper BEM scoping
'styles/file-selector-modal.css', // FileSelectorModal component with proper BEM scoping
'styles/unscheduled-tasks-selector-modal.css', // UnscheduledTasksSelectorModal component with proper BEM scoping
'styles/task-action-palette-modal.css', // TaskActionPaletteModal component with proper BEM scoping
'styles/time-entry-editor-modal.css', // TimeEntryEditorModal component with proper BEM scoping

View file

@ -3,7 +3,7 @@ import TaskNotesPlugin from "../main";
import { ICSEvent } from "../types";
import { ICSEventInfoModal } from "../modals/ICSEventInfoModal";
import { ICSNoteCreationModal } from "../modals/ICSNoteCreationModal";
import { ICSNoteLinkModal } from "../modals/ICSNoteLinkModal";
import { openFileSelector } from "../modals/FileSelectorModal";
import { SafeAsync } from "../utils/safeAsync";
import { ContextMenu } from "./ContextMenu";
@ -205,34 +205,34 @@ export class ICSEventContextMenu {
private async linkExistingNote(): Promise<void> {
await SafeAsync.execute(
async () => {
const modal = new ICSNoteLinkModal(
this.options.plugin.app,
this.options.plugin,
async (file) => {
await SafeAsync.execute(
async () => {
await this.options.plugin.icsNoteService.linkNoteToICS(
file.path,
this.options.icsEvent
);
new Notice(
this.t("contextMenus.ics.notices.linkSuccess", {
name: file.name,
})
);
openFileSelector(this.options.plugin, async (file) => {
if (!file) return;
// Trigger update callback if provided
if (this.options.onUpdate) {
this.options.onUpdate();
}
},
{
errorMessage: this.t("contextMenus.ics.notices.linkFailure"),
await SafeAsync.execute(
async () => {
await this.options.plugin.icsNoteService.linkNoteToICS(
file.path,
this.options.icsEvent
);
new Notice(
this.t("contextMenus.ics.notices.linkSuccess", {
name: file.name,
})
);
// Trigger update callback if provided
if (this.options.onUpdate) {
this.options.onUpdate();
}
);
}
);
modal.open();
},
{
errorMessage: this.t("contextMenus.ics.notices.linkFailure"),
}
);
}, {
placeholder: "Search notes to link...",
filter: "markdown",
});
},
{
errorMessage: this.t("contextMenus.ics.notices.linkSelectionFailure"),

View file

@ -1,88 +0,0 @@
import {
App,
FuzzySuggestModal,
TAbstractFile,
TFile,
SearchResult,
parseFrontMatterAliases,
} from "obsidian";
import type TaskNotesPlugin from "../main";
export class AttachmentSelectModal extends FuzzySuggestModal<TAbstractFile> {
private onChoose: (file: TAbstractFile) => void;
private plugin: TaskNotesPlugin;
constructor(app: App, plugin: TaskNotesPlugin, onChoose: (file: TAbstractFile) => void) {
super(app);
this.plugin = plugin;
this.onChoose = onChoose;
this.setPlaceholder("Type to search for files and notes...");
this.setInstructions([
{ command: "↑↓", purpose: "to navigate" },
{ command: "↵", purpose: "to select" },
{ command: "esc", purpose: "to cancel" },
]);
}
getItems(): TAbstractFile[] {
return this.app.vault.getAllLoadedFiles();
}
getItemText(file: TAbstractFile): string {
let text = `${file.name} ${file.path}`;
// Add aliases to searchable text
if (file instanceof TFile) {
const cache = this.app.metadataCache.getFileCache(file);
if (cache?.frontmatter) {
const aliases = parseFrontMatterAliases(cache.frontmatter);
if (aliases && aliases.length > 0) {
text += ` ${aliases.join(" ")}`;
}
}
}
return text;
}
renderSuggestion(value: { item: TAbstractFile; match: SearchResult }, el: HTMLElement) {
const file = value.item;
el.empty();
const container = el.createDiv({ cls: "attachment-suggestion" });
// File name (main line)
const nameEl = container.createSpan({ cls: "attachment-name" });
nameEl.textContent = file.name;
// Title or aliases (second line)
if (file instanceof TFile) {
const cache = this.app.metadataCache.getFileCache(file);
if (cache?.frontmatter) {
const titleField = this.plugin.fieldMapper.toUserField("title");
const title = cache.frontmatter[titleField];
if (title) {
const titleEl = container.createDiv({ cls: "attachment-title" });
titleEl.textContent = title;
} else {
const aliases = parseFrontMatterAliases(cache.frontmatter);
if (aliases && aliases.length > 0) {
const aliasEl = container.createDiv({ cls: "attachment-aliases" });
aliasEl.textContent = aliases.join(", ");
}
}
}
}
// File path (if different from name)
if (file.path !== file.name) {
const pathEl = container.createDiv({ cls: "attachment-path" });
pathEl.textContent = file.path;
}
}
onChooseItem(file: TAbstractFile, evt: MouseEvent | KeyboardEvent) {
this.onChoose(file);
}
}

View file

@ -0,0 +1,359 @@
import {
App,
SuggestModal,
TAbstractFile,
TFile,
TFolder,
parseFrontMatterAliases,
Notice,
} from "obsidian";
import type TaskNotesPlugin from "../main";
export type FileSelectorResult =
| { type: "selected"; file: TAbstractFile }
| { type: "created"; file: TFile }
| { type: "cancelled" };
export interface FileSelectorOptions {
/** Callback when a file is selected or created */
onResult: (result: FileSelectorResult) => void;
/** Optional placeholder text */
placeholder?: string;
/** Optional title */
title?: string;
/** Filter to apply to files (default: markdown files only) */
filter?: "markdown" | "all" | ((file: TAbstractFile) => boolean);
/** Folder to create new files in (default: vault root) */
newFileFolder?: string;
}
/**
* A generic file selector modal that allows users to:
* 1. Select an existing file from a fuzzy search
* 2. Create a new file by pressing Shift+Enter
*/
export class FileSelectorModal extends SuggestModal<TAbstractFile> {
private plugin: TaskNotesPlugin;
private options: FileSelectorOptions;
private currentQuery: string = "";
private resultHandled: boolean = false;
private createFooterEl: HTMLElement | null = null;
constructor(
app: App,
plugin: TaskNotesPlugin,
options: FileSelectorOptions
) {
super(app);
this.plugin = plugin;
this.options = options;
// Set placeholder
this.setPlaceholder(
options.placeholder || "Search files or type to create new..."
);
// Set instructions
this.setInstructions([
{ command: "↑↓", purpose: "to navigate" },
{ command: "↵", purpose: "to select" },
{ command: "⇧↵", purpose: "to create new" },
{ command: "esc", purpose: "to cancel" },
]);
// Set modal title
if (options.title) {
this.titleEl.setText(options.title);
}
// Add classes for styling
this.containerEl.addClass("file-selector-modal");
this.containerEl.addClass("tasknotes-plugin");
}
onOpen(): void {
super.onOpen();
// Register Shift+Enter for creating new file
this.scope.register(["Shift"], "Enter", (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
this.createNewFile();
return false;
});
// Track input changes for the footer preview
this.inputEl.addEventListener("input", this.handleInputChange);
// Create footer after DOM is ready
setTimeout(() => this.createFooter(), 0);
}
private handleInputChange = (): void => {
this.currentQuery = this.inputEl.value.trim();
this.updateCreateFooter();
};
private createFooter(): void {
const promptEl = this.modalEl.querySelector(".prompt");
if (!promptEl) return;
this.createFooterEl = promptEl.parentElement?.createDiv({
cls: "file-selector-create-footer",
}) || null;
if (this.createFooterEl) {
this.createFooterEl.style.display = "none";
}
}
private updateCreateFooter(): void {
if (!this.createFooterEl) return;
if (!this.currentQuery) {
this.createFooterEl.style.display = "none";
return;
}
this.createFooterEl.empty();
this.createFooterEl.style.display = "flex";
// Content
const contentDiv = this.createFooterEl.createDiv({
cls: "file-selector-create-footer__content",
});
// Title line
const titleLine = contentDiv.createDiv({
cls: "file-selector-create-footer__title-line",
});
const shortcut = titleLine.createSpan({
cls: "file-selector-create-footer__shortcut",
text: "⇧↵",
});
titleLine.createSpan({
cls: "file-selector-create-footer__hint-text",
text: " to create: ",
});
titleLine.createSpan({
cls: "file-selector-create-footer__filename",
text: this.getNewFileName(),
});
}
private getNewFileName(): string {
let name = this.currentQuery;
// Remove .md extension if user typed it
if (name.toLowerCase().endsWith(".md")) {
name = name.slice(0, -3);
}
return name + ".md";
}
private async createNewFile(): Promise<void> {
if (!this.currentQuery) {
new Notice("Please enter a file name");
return;
}
try {
let fileName = this.currentQuery;
// Remove .md extension if user typed it (we'll add it back)
if (fileName.toLowerCase().endsWith(".md")) {
fileName = fileName.slice(0, -3);
}
// Determine the folder
const folderPath = this.options.newFileFolder || "";
const filePath = folderPath
? `${folderPath}/${fileName}.md`
: `${fileName}.md`;
// Check if file already exists
const existingFile = this.app.vault.getAbstractFileByPath(filePath);
if (existingFile) {
new Notice(`File "${filePath}" already exists`);
return;
}
// Ensure folder exists
if (folderPath) {
const folder = this.app.vault.getAbstractFileByPath(folderPath);
if (!folder) {
await this.app.vault.createFolder(folderPath);
}
}
// Create the file
const newFile = await this.app.vault.create(filePath, "");
this.resultHandled = true;
this.close();
this.options.onResult({ type: "created", file: newFile });
} catch (error) {
console.error("Error creating file:", error);
new Notice("Failed to create file");
}
}
getSuggestions(query: string): TAbstractFile[] {
this.currentQuery = query.trim();
this.updateCreateFooter();
const allFiles = this.app.vault.getAllLoadedFiles();
const lowerQuery = query.toLowerCase();
// Apply filter
let filtered: TAbstractFile[];
const filter = this.options.filter || "markdown";
if (typeof filter === "function") {
filtered = allFiles.filter(filter);
} else if (filter === "markdown") {
filtered = allFiles.filter(
(file) =>
file instanceof TFile &&
file.extension === "md" &&
!file.path.includes(".trash")
);
} else {
filtered = allFiles.filter(
(file) =>
file instanceof TFile && !file.path.includes(".trash")
);
}
// Filter by query
if (!query) {
return filtered.slice(0, 50); // Limit initial results
}
return filtered
.filter((file) => {
const searchText = this.getSearchText(file).toLowerCase();
return searchText.includes(lowerQuery);
})
.slice(0, 50);
}
private getSearchText(file: TAbstractFile): string {
let text = `${file.name} ${file.path}`;
if (file instanceof TFile) {
const cache = this.app.metadataCache.getFileCache(file);
if (cache?.frontmatter) {
// Add title
const titleField = this.plugin.fieldMapper.toUserField("title");
const title = cache.frontmatter[titleField];
if (title) {
text += ` ${title}`;
}
// Add aliases
const aliases = parseFrontMatterAliases(cache.frontmatter);
if (aliases && aliases.length > 0) {
text += ` ${aliases.join(" ")}`;
}
}
}
return text;
}
renderSuggestion(file: TAbstractFile, el: HTMLElement): void {
const container = el.createDiv({ cls: "file-selector-suggestion" });
// File name
container.createDiv({
cls: "file-selector-suggestion__name",
text: file.name,
});
// Title or aliases (for markdown files)
if (file instanceof TFile) {
const cache = this.app.metadataCache.getFileCache(file);
if (cache?.frontmatter) {
const titleField = this.plugin.fieldMapper.toUserField("title");
const title = cache.frontmatter[titleField];
if (title) {
container.createDiv({
cls: "file-selector-suggestion__title",
text: title,
});
} else {
const aliases = parseFrontMatterAliases(cache.frontmatter);
if (aliases && aliases.length > 0) {
container.createDiv({
cls: "file-selector-suggestion__aliases",
text: aliases.join(", "),
});
}
}
}
}
// Path (if not in root)
if (file.parent && file.parent.path !== "/") {
container.createDiv({
cls: "file-selector-suggestion__path",
text: file.parent.path,
});
}
}
onChooseSuggestion(file: TAbstractFile, evt: MouseEvent | KeyboardEvent): void {
this.resultHandled = true;
this.options.onResult({ type: "selected", file });
}
onClose(): void {
this.inputEl.removeEventListener("input", this.handleInputChange);
if (this.createFooterEl) {
this.createFooterEl.remove();
this.createFooterEl = null;
}
// Defer cancelled check (same pattern as TaskSelectorWithCreateModal)
setTimeout(() => {
if (!this.resultHandled) {
this.options.onResult({ type: "cancelled" });
}
}, 0);
super.onClose();
}
}
/**
* Helper function to open a file selector modal
*/
export function openFileSelector(
plugin: TaskNotesPlugin,
onChoose: (file: TAbstractFile | null) => void,
options?: {
placeholder?: string;
title?: string;
filter?: "markdown" | "all" | ((file: TAbstractFile) => boolean);
newFileFolder?: string;
}
): void {
const modal = new FileSelectorModal(plugin.app, plugin, {
placeholder: options?.placeholder,
title: options?.title,
filter: options?.filter,
newFileFolder: options?.newFileFolder,
onResult: (result) => {
if (result.type === "selected" || result.type === "created") {
onChoose(result.file);
} else {
onChoose(null);
}
},
});
modal.open();
}

View file

@ -3,7 +3,7 @@ import { App, Modal, Setting, Notice, TFile } from "obsidian";
import TaskNotesPlugin from "../main";
import { ICSEvent, TaskInfo, NoteInfo } from "../types";
import { ICSNoteCreationModal } from "./ICSNoteCreationModal";
import { ICSNoteLinkModal } from "./ICSNoteLinkModal";
import { openFileSelector } from "./FileSelectorModal";
import { SafeAsync } from "../utils/safeAsync";
import { TranslationKey } from "../i18n";
@ -185,10 +185,11 @@ export class ICSEventInfoModal extends Modal {
}
private async linkExistingNote(): Promise<void> {
console.log("Link existing note button clicked");
await SafeAsync.execute(
async () => {
const modal = new ICSNoteLinkModal(this.app, this.plugin, async (file) => {
openFileSelector(this.plugin, async (file) => {
if (!file) return;
await SafeAsync.execute(
async () => {
await this.plugin.icsNoteService.linkNoteToICS(
@ -202,8 +203,10 @@ export class ICSEventInfoModal extends Modal {
errorMessage: "Failed to link note",
}
);
}, {
placeholder: "Search notes to link...",
filter: "markdown",
});
modal.open();
},
{
errorMessage: "Failed to open note selection",

View file

@ -1,99 +0,0 @@
import {
App,
FuzzySuggestModal,
TAbstractFile,
TFile,
SearchResult,
parseFrontMatterAliases,
} from "obsidian";
import type TaskNotesPlugin from "../main";
/**
* Modal for selecting notes to link to ICS events using fuzzy search
* Based on the existing ProjectSelectModal pattern
*/
export class ICSNoteLinkModal extends FuzzySuggestModal<TAbstractFile> {
private onChoose: (file: TAbstractFile) => void;
private plugin: TaskNotesPlugin;
constructor(app: App, plugin: TaskNotesPlugin, onChoose: (file: TAbstractFile) => void) {
super(app);
this.plugin = plugin;
this.onChoose = onChoose;
this.setPlaceholder("Type to search for notes to link...");
this.setInstructions([
{ command: "↑↓", purpose: "to navigate" },
{ command: "↵", purpose: "to select" },
{ command: "esc", purpose: "to cancel" },
]);
}
getItems(): TAbstractFile[] {
return this.app.vault
.getAllLoadedFiles()
.filter(
(file) =>
file instanceof TFile &&
file.extension === "md" &&
!file.path.includes(".trash")
);
}
getItemText(file: TAbstractFile): string {
let text = `${file.name} ${file.path}`;
// Add aliases to searchable text
if (file instanceof TFile) {
const cache = this.app.metadataCache.getFileCache(file);
if (cache?.frontmatter) {
const aliases = parseFrontMatterAliases(cache.frontmatter);
if (aliases && aliases.length > 0) {
text += ` ${aliases.join(" ")}`;
}
}
}
return text;
}
renderSuggestion(value: { item: TAbstractFile; match: SearchResult }, el: HTMLElement) {
const file = value.item;
el.empty();
const container = el.createDiv({ cls: "ics-note-link-suggestion" });
// File name (main line)
const nameEl = container.createSpan({ cls: "note-link-name" });
nameEl.textContent = file.name;
// Title or aliases (second line)
if (file instanceof TFile) {
const cache = this.app.metadataCache.getFileCache(file);
if (cache?.frontmatter) {
const titleField = this.plugin.fieldMapper.toUserField("title");
const title = cache.frontmatter[titleField];
if (title) {
const titleEl = container.createDiv({ cls: "note-link-title" });
titleEl.textContent = title;
} else {
const aliases = parseFrontMatterAliases(cache.frontmatter);
if (aliases && aliases.length > 0) {
const aliasEl = container.createDiv({ cls: "note-link-aliases" });
aliasEl.textContent = aliases.join(", ");
}
}
}
}
// File path (if different from name)
if (file.path !== file.name) {
const pathEl = container.createDiv({ cls: "note-link-path" });
pathEl.textContent = file.path;
}
}
onChooseItem(file: TAbstractFile, evt: MouseEvent | KeyboardEvent) {
this.onChoose(file);
}
}

View file

@ -11,7 +11,7 @@ import {
import TaskNotesPlugin from "../main";
import { TimeBlock, DailyNoteFrontmatter } from "../types";
import { generateTimeblockId } from "../utils/helpers";
import { AttachmentSelectModal } from "./AttachmentSelectModal";
import { openFileSelector } from "./FileSelectorModal";
import { parseDateAsLocal } from "../utils/dateUtils";
import {
createDailyNote,
@ -148,10 +148,12 @@ export class TimeblockCreationModal extends Modal {
.setButtonText(this.translate("modals.timeblockCreation.addAttachmentButton"))
.setTooltip(this.translate("modals.timeblockCreation.addAttachmentTooltip"))
.onClick(() => {
const modal = new AttachmentSelectModal(this.app, this.plugin, (file) => {
this.addAttachment(file);
openFileSelector(this.plugin, (file) => {
if (file) this.addAttachment(file);
}, {
placeholder: "Search files or type to create new...",
filter: "all",
});
modal.open();
});
});

View file

@ -10,7 +10,7 @@ import {
setTooltip,
} from "obsidian";
import TaskNotesPlugin from "../main";
import { AttachmentSelectModal } from "./AttachmentSelectModal";
import { openFileSelector } from "./FileSelectorModal";
import {
getDailyNote,
getAllDailyNotes,
@ -130,10 +130,12 @@ export class TimeblockInfoModal extends Modal {
.setButtonText(this.translate("modals.timeblockInfo.addAttachmentButton"))
.setTooltip(this.translate("modals.timeblockInfo.addAttachmentTooltip"))
.onClick(() => {
const modal = new AttachmentSelectModal(this.app, this.plugin, (file) => {
this.addAttachment(file);
openFileSelector(this.plugin, (file) => {
if (file) this.addAttachment(file);
}, {
placeholder: "Search files or type to create new...",
filter: "all",
});
modal.open();
});
});

View file

@ -0,0 +1,92 @@
/* ================================================
FILE SELECTOR MODAL
================================================ */
/* ================================================
SUGGESTION STYLING
================================================ */
.file-selector-suggestion {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--tn-spacing-xs) 0;
}
.file-selector-suggestion__name {
font-weight: 500;
font-size: var(--tn-font-size-md);
color: var(--text-normal);
}
.file-selector-suggestion__title,
.file-selector-suggestion__aliases {
font-size: var(--tn-font-size-sm);
color: var(--text-muted);
}
.file-selector-suggestion__path {
font-size: var(--tn-font-size-xs);
color: var(--text-faint);
}
/* ================================================
CREATE FOOTER
================================================ */
.file-selector-create-footer {
display: flex;
align-items: center;
gap: var(--tn-spacing-md);
padding: var(--tn-spacing-md) var(--tn-spacing-lg);
background: var(--background-secondary);
border-top: 1px solid var(--background-modifier-border);
margin-top: auto;
}
.file-selector-create-footer__content {
flex: 1;
min-width: 0;
}
.file-selector-create-footer__title-line {
display: flex;
align-items: center;
gap: var(--tn-spacing-xs);
}
.file-selector-create-footer__shortcut {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 3px;
background-color: var(--background-modifier-border);
color: var(--text-faint);
font-size: 10px;
font-weight: 500;
font-family: var(--font-monospace);
}
.file-selector-create-footer__hint-text {
font-size: var(--tn-font-size-sm);
color: var(--text-faint);
}
.file-selector-create-footer__filename {
font-size: var(--tn-font-size-sm);
color: var(--text-normal);
font-weight: 500;
}
/* ================================================
DARK THEME ADJUSTMENTS
================================================ */
.theme-dark .file-selector-create-footer {
background: var(--background-secondary-alt);
}
.theme-dark .file-selector-create-footer__shortcut {
background-color: var(--background-modifier-form-field);
}

View file

@ -42,11 +42,11 @@
display: flex;
align-items: flex-start;
gap: var(--tn-spacing-md);
padding: var(--tn-spacing-sm) var(--tn-spacing-md);
padding: var(--tn-spacing-md) var(--tn-spacing-lg);
background: var(--background-secondary);
border-top: 1px solid var(--background-modifier-border);
margin-top: auto;
min-height: 50px;
min-height: 60px;
}
.task-selector-create-footer__icon {
@ -71,7 +71,7 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--tn-spacing-xs);
gap: var(--tn-spacing-sm);
}
/* ================================================
@ -100,15 +100,16 @@
.task-selector-create-footer__meta {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
flex-wrap: wrap;
margin-top: var(--tn-spacing-xs);
}
.task-selector-create-footer__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
padding: 3px 10px;
border-radius: 12px;
font-size: var(--tn-font-size-xs);
font-weight: 500;