From c21d8b1097c51f8315716dcf265483d5c6fe39d6 Mon Sep 17 00:00:00 2001 From: Matt Wiseley Date: Sun, 29 Mar 2026 09:56:40 -0400 Subject: [PATCH] Tweaks & fixes --- LICENSE.txt | 21 +++++++++++ requirements.md | 29 ++++++++++----- src/constants.ts | 1 + src/file-intercept.ts | 30 ++++++++++++++- src/main.ts | 15 -------- src/parser.ts | 50 ++++++++++++++++++------- src/settings.ts | 16 ++++++++ src/view/FileSwitcherBar.ts | 73 ++++++++++++++++++++++++++----------- src/view/TaskEditorModal.ts | 58 +++++++++++++++++++++++++++-- src/view/TaskFileView.ts | 62 ++++++++++++++++++++++--------- styles.css | 36 ++++++++++++++++++ 11 files changed, 310 insertions(+), 81 deletions(-) create mode 100644 LICENSE.txt diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..013c32d --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 1986 bagaag.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/requirements.md b/requirements.md index 79e26cc..01164e6 100644 --- a/requirements.md +++ b/requirements.md @@ -16,14 +16,16 @@ An Obsidian plugin that provides a more traditional task management GUI for desi ### 1.2 Structure -- Task files consist entirely of markdown checklist items (`- [ ] …` / `- [x] …`). +- Task files consist of markdown checklist items (`- [ ] …` / `- [x] …`) and optional header content. +- Any non-checklist lines **before the first checklist item** are treated as **header text**: they are not parsed as tasks, not shown in the UI, and are preserved verbatim when the file is written back. - Any non-checklist lines immediately following a checklist item are treated as **notes** for that item. +- Notes are separated from their parent task in the file by a blank line. - The ordering of checklist items in the file reflects the display order in the UI. - Completed items remain in the file until the user marks them as done, at which point they are moved to the current Daily Note (see §5). ### 1.3 Inbox File -- One task file is designated as the **Inbox** — the default destination for new tasks. +- One task file is designated as the **Inbox** — the default destination when opened via the command palette. - The Inbox file path is configured in plugin settings. - Default: `Tasks/Inbox.md`. @@ -31,7 +33,13 @@ An Obsidian plugin that provides a more traditional task management GUI for desi ## 2. Task View UI -### 2.1 Layout +### 2.1 Source Mode + +- The task view header contains an **"Edit source"** action button (code icon). +- Clicking it switches the current leaf to Obsidian's standard markdown editor in source mode, bypassing the normal intercept that redirects task files to the task view. +- To return to task view, use the tab's **"Open as… > YAOTP Task View"** context menu option, or close and reopen the file. + +### 2.2 Layout - The custom view replaces the standard markdown editor for task files. - Each checklist item is displayed as a task row containing: @@ -40,14 +48,14 @@ An Obsidian plugin that provides a more traditional task management GUI for desi - Notes are **not** shown inline; they are accessible via the task editor (§3). - The view does not render raw markdown syntax. -### 2.2 Reordering +### 2.3 Reordering - Task rows can be reordered by dragging and dropping within the view. - On mobile, reordering is triggered by a long-press on the drag handle, followed by dragging. - Reordering the UI immediately reorders the corresponding checklist items in the markdown file. - Notes travel with their parent task during reordering. -### 2.3 File Switcher +### 2.4 File Switcher - A header bar or sidebar control lets the user quickly switch between: - The Inbox file. @@ -57,7 +65,7 @@ An Obsidian plugin that provides a more traditional task management GUI for desi - Creates the file in the configured tasks folder (or a location derived from the regex). - Opens the new file in the task view. -### 2.3 Adding a Task +### 2.5 Adding a Task - The first row of every task list is a persistent **add-task row** that looks identical to a regular task row. - The add-task row contains: @@ -68,7 +76,7 @@ An Obsidian plugin that provides a more traditional task management GUI for desi - The new task is immediately persisted to the markdown file. - The add-task row is always visible, even when the list is empty. -### 2.4 Completing a Task +### 2.6 Completing a Task - Clicking a task's checkbox marks it as complete. - The task (and its notes) is removed from the current task file. @@ -79,11 +87,13 @@ An Obsidian plugin that provides a more traditional task management GUI for desi ## 3. Task Editor -- Clicking the **text** of a task opens an editor modal or inline panel. +- Clicking the **text** of a task opens an editor modal. - The editor provides: - - A single **multiline text area** containing the task text on the first line, followed by the notes. The first line is the task title; any subsequent lines are notes. The user edits both in one continuous field. + - A single **multiline text area** with the task title on the first line, a blank line, then the notes. The blank line separator is always present when notes exist; if the user removes it or writes notes without it, a blank line is inserted automatically on save. - A **file selector** (dropdown or fuzzy-search picker) to move the task to a different task file. Selecting a different file removes the task from the current file and appends it to the bottom of the selected file. + - A **Delete** button that, after a confirmation prompt, permanently removes the task from the file. - Changes are saved back to the markdown file on confirm/close. +- The **"+ New list"** button opens a modal prompting for a file name and creates the file in the configured tasks folder. --- @@ -123,6 +133,7 @@ An Obsidian plugin that provides a more traditional task management GUI for desi |---|---|---| | **Inbox file path** | Vault-relative path to the Inbox task file | `Tasks/Inbox.md` | | **Task file regex** | Regex matched against vault-relative file paths to identify task files | `^Tasks\/.*\.md$` | +| **Task file folder** | Vault-relative folder where new task lists are created via the "New list" button; leave empty for the vault root | `Tasks` | | **Daily note date format** | Date format string used to locate the daily note (should match Daily Notes plugin setting) | `YYYY-MM-DD` | | **Daily note folder** | Folder where daily notes are stored (should match Daily Notes plugin setting) | *(empty — vault root)* | diff --git a/src/constants.ts b/src/constants.ts index 4b31364..4b96ba9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,5 +5,6 @@ export const DEFAULT_INBOX_PATH = 'Tasks/Inbox.md'; export const DEFAULT_TASK_REGEX = '^Tasks\\/.*\\.md$'; export const DEFAULT_DAILY_NOTE_FORMAT = 'YYYY-MM-DD'; export const DEFAULT_DAILY_NOTE_FOLDER = ''; +export const DEFAULT_TASK_FILE_FOLDER = 'Tasks'; export const DAILY_NOTE_TASKS_HEADING = '#### Tasks'; diff --git a/src/file-intercept.ts b/src/file-intercept.ts index da97d9b..480f843 100644 --- a/src/file-intercept.ts +++ b/src/file-intercept.ts @@ -5,6 +5,21 @@ type SetViewState = WorkspaceLeaf['setViewState']; let originalSetViewState: SetViewState | null = null; +/** + * Leaves that have been granted a one-shot bypass of the intercept. + * Used to allow the task view's "Source mode" button to switch to the + * markdown editor without being redirected back. + */ +const bypassLeaves = new WeakSet(); + +/** + * Allow the next setViewState call on this leaf to pass through the + * intercept unmodified. Call this immediately before setViewState. + */ +export function allowMarkdownOnce(leaf: WorkspaceLeaf): void { + bypassLeaves.add(leaf); +} + /** * Monkey-patch WorkspaceLeaf.prototype.setViewState so that opening a * task file (identified by the given predicate) redirects to our custom @@ -25,8 +40,21 @@ export function installFileIntercept(isTaskFile: (path: string) => boolean): voi if ( state?.type === 'markdown' && state?.state?.file && - isTaskFile(state.state.file as string) + isTaskFile(state.state.file as string) && + // Don't intercept mode-only changes within an already-open markdown view + // for the same file (e.g. switching to Source mode via the context menu). + // We DO still intercept if the leaf is markdown but showing a different + // file, or if the view is not yet markdown at all. + !( + this.view?.getViewType() === 'markdown' && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.view as any)?.file?.path === (state.state?.file as string) + ) ) { + if (bypassLeaves.has(this)) { + bypassLeaves.delete(this); + return originalSetViewState!.call(this, state, eState); + } const newState = { ...state, type: VIEW_TYPE }; return originalSetViewState!.call(this, newState, eState); } diff --git a/src/main.ts b/src/main.ts index d59e5e5..14bf799 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,12 +31,6 @@ export default class YaotpPlugin extends Plugin { // Settings tab this.addSettingTab(new YaotpSettingTab(this.app, this)); - // Command: open inbox - this.addCommand({ - id: 'open-inbox', - name: 'Open Inbox', - callback: () => this.openInbox(), - }); } onunload(): void { @@ -84,13 +78,4 @@ export default class YaotpPlugin extends Plugin { }); } - private async openInbox(): Promise { - const { inboxPath } = this.settings; - let file = this.app.vault.getAbstractFileByPath(inboxPath) as TFile | null; - if (!file) { - file = await this.app.vault.create(inboxPath, ''); - } - const leaf = this.app.workspace.getLeaf(false); - await leaf.openFile(file); - } } diff --git a/src/parser.ts b/src/parser.ts index 6ae3725..b88640f 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2,8 +2,14 @@ import type { Task } from './types'; const CHECKLIST_RE = /^- \[([ xX])\] (.*)$/; +export interface TaskFileData { + /** Raw text before the first checklist item, preserved verbatim on serialize. */ + header: string; + tasks: Task[]; +} + /** - * Parse a task file's markdown content into an array of Tasks. + * Parse a task file's markdown content into header text and an array of Tasks. * * Format expected: * - [ ] Task title @@ -13,13 +19,14 @@ const CHECKLIST_RE = /^- \[([ xX])\] (.*)$/; * * - [ ] Next task * - * Notes are lines between two blank-line boundaries following a checklist item. - * Blank lines that are not between a checklist item and notes, or between notes - * lines, are ignored. + * Any non-checklist lines before the first task are captured as `header` and + * round-tripped unchanged. Notes are lines between two blank-line boundaries + * following a checklist item. */ -export function parseTaskFile(markdown: string): Task[] { +export function parseTaskFile(markdown: string): TaskFileData { const lines = markdown.split('\n'); const tasks: Task[] = []; + const headerLines: string[] = []; let current: Task | null = null; // State: 'idle' | 'after-task' | 'in-notes' // after-task: saw checklist line, waiting for blank line or next checklist @@ -56,7 +63,8 @@ export function parseTaskFile(markdown: string): Task[] { } if (current === null) { - // No active task; ignore non-checklist lines before the first task + // Before the first task — capture as header + headerLines.push(line); continue; } @@ -82,13 +90,22 @@ export function parseTaskFile(markdown: string): Task[] { } flush(); - return tasks; + + // Trim trailing blank lines from header + while (headerLines.length > 0 && headerLines[headerLines.length - 1].trim() === '') { + headerLines.pop(); + } + const header = headerLines.length > 0 ? headerLines.join('\n') + '\n' : ''; + + return { header, tasks }; } /** - * Serialize an array of Tasks back to canonical markdown. + * Serialize a TaskFileData back to canonical markdown. + * + * The header (if any) is written verbatim first, separated from tasks by a + * blank line. Task format: * - * Format produced: * - [ ] Task title * * notes line 1 @@ -96,11 +113,13 @@ export function parseTaskFile(markdown: string): Task[] { * * - [ ] Next task * - * Tasks with no notes have no trailing blank line before the next task, - * except the final newline at EOF. + * Tasks with notes always have a blank line between the checklist line and the + * first note line. Completed items use `- [x] …`. */ -export function serializeTaskFile(tasks: Task[]): string { - if (tasks.length === 0) return ''; +export function serializeTaskFile(data: TaskFileData): string { + const { header, tasks } = data; + + if (tasks.length === 0) return header || ''; const parts: string[] = []; @@ -119,5 +138,8 @@ export function serializeTaskFile(tasks: Task[]): string { } // Join and ensure single trailing newline - return parts.join('\n').replace(/\n+$/, '') + '\n'; + const taskContent = parts.join('\n').replace(/\n+$/, '') + '\n'; + + // header already ends with '\n'; add one more for the blank separator line + return header ? header + '\n' + taskContent : taskContent; } diff --git a/src/settings.ts b/src/settings.ts index deb8954..5b30cc5 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -3,6 +3,7 @@ import { DEFAULT_DAILY_NOTE_FOLDER, DEFAULT_DAILY_NOTE_FORMAT, DEFAULT_INBOX_PATH, + DEFAULT_TASK_FILE_FOLDER, DEFAULT_TASK_REGEX, } from './constants'; import type YaotpPlugin from './main'; @@ -10,6 +11,7 @@ import type YaotpPlugin from './main'; export interface YaotpSettings { inboxPath: string; taskFileRegex: string; + taskFileFolder: string; dailyNoteFormat: string; dailyNoteFolder: string; } @@ -17,6 +19,7 @@ export interface YaotpSettings { export const DEFAULT_SETTINGS: YaotpSettings = { inboxPath: DEFAULT_INBOX_PATH, taskFileRegex: DEFAULT_TASK_REGEX, + taskFileFolder: DEFAULT_TASK_FILE_FOLDER, dailyNoteFormat: DEFAULT_DAILY_NOTE_FORMAT, dailyNoteFolder: DEFAULT_DAILY_NOTE_FOLDER, }; @@ -69,6 +72,19 @@ export class YaotpSettingTab extends PluginSettingTab { text.inputEl.style.width = '100%'; }); + new Setting(containerEl) + .setName('Task file folder') + .setDesc('Vault-relative folder where new task lists are created. Leave empty for the vault root.') + .addText((text) => + text + .setPlaceholder('Tasks') + .setValue(this.plugin.settings.taskFileFolder) + .onChange(async (value) => { + this.plugin.settings.taskFileFolder = value.trim(); + await this.plugin.saveSettings(); + }) + ); + new Setting(containerEl) .setName('Daily note date format') .setDesc( diff --git a/src/view/FileSwitcherBar.ts b/src/view/FileSwitcherBar.ts index c4537e9..1d9418d 100644 --- a/src/view/FileSwitcherBar.ts +++ b/src/view/FileSwitcherBar.ts @@ -1,7 +1,7 @@ -import { App, TFile } from 'obsidian'; +import { TFile } from 'obsidian'; export interface FileSwitcherBarOptions { - app: App; + app: unknown; // kept for interface compatibility, unused after modal removal currentFile: TFile; taskFiles: TFile[]; inboxPath: string; @@ -10,8 +10,8 @@ export interface FileSwitcherBarOptions { } /** - * Renders a header bar with a file switcher dropdown and a "New task file" button. - * Returns the container element to be prepended into the view's root. + * Renders a header bar with a file switcher dropdown and an inline + * "New task file" affordance. Returns the container element. */ export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement { const { currentFile, taskFiles, inboxPath, onSwitchFile, onCreateFile } = opts; @@ -20,39 +20,70 @@ export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement // Left side: file selector const selectWrap = bar.createDiv({ cls: 'yaotp-switcher-select-wrap' }); - const select = selectWrap.createEl('select', { cls: 'yaotp-switcher-select' }); // Always show Inbox first if it exists in the list const inbox = taskFiles.find((f) => f.path === inboxPath); const others = taskFiles.filter((f) => f.path !== inboxPath); - const ordered = inbox ? [inbox, ...others] : others; - - for (const file of ordered) { - const opt = select.createEl('option', { - text: file.basename, - value: file.path, - }); - if (file.path === currentFile.path) { - opt.selected = true; - } + for (const file of (inbox ? [inbox, ...others] : others)) { + const opt = select.createEl('option', { text: file.basename, value: file.path }); + if (file.path === currentFile.path) opt.selected = true; } - select.addEventListener('change', () => { const chosen = taskFiles.find((f) => f.path === select.value); if (chosen) onSwitchFile(chosen); }); - // Right side: new file button + // Right side: "+ New list" button / inline create input (mutually exclusive) const newBtn = bar.createEl('button', { text: '+ New list', cls: 'yaotp-switcher-new-btn', }); - newBtn.addEventListener('click', () => { - const name = prompt('New task file name (without .md):'); - if (name && name.trim()) { - onCreateFile(name.trim()); + + const createGroup = bar.createDiv({ cls: 'yaotp-switcher-create-group' }); + createGroup.style.display = 'none'; + + const nameInput = createGroup.createEl('input', { + type: 'text', + cls: 'yaotp-switcher-name-input', + attr: { placeholder: 'File name (without .md)' }, + }) as HTMLInputElement; + + const confirmBtn = createGroup.createEl('button', { + text: 'Create', + cls: 'mod-cta yaotp-switcher-confirm-btn', + }); + const cancelBtn = createGroup.createEl('button', { + text: '✕', + cls: 'yaotp-switcher-cancel-btn', + }); + + const showInput = () => { + newBtn.style.display = 'none'; + createGroup.style.display = 'flex'; + nameInput.value = ''; + setTimeout(() => nameInput.focus(), 0); + }; + + const hideInput = () => { + createGroup.style.display = 'none'; + newBtn.style.display = ''; + }; + + const submit = () => { + const name = nameInput.value.trim(); + if (name) { + hideInput(); + onCreateFile(name); } + }; + + newBtn.addEventListener('click', showInput); + confirmBtn.addEventListener('click', submit); + cancelBtn.addEventListener('click', hideInput); + nameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') submit(); + if (e.key === 'Escape') hideInput(); }); return bar; diff --git a/src/view/TaskEditorModal.ts b/src/view/TaskEditorModal.ts index c45a700..c07d726 100644 --- a/src/view/TaskEditorModal.ts +++ b/src/view/TaskEditorModal.ts @@ -9,6 +9,7 @@ export interface TaskEditorResult { } type SaveCallback = (result: TaskEditorResult) => void; +type DeleteCallback = () => void; /** * Modal for editing a task's text and notes together, and optionally @@ -19,6 +20,7 @@ export class TaskEditorModal extends Modal { private taskFiles: TFile[]; private currentFile: TFile; private onSave: SaveCallback; + private onDelete: DeleteCallback; private textarea: HTMLTextAreaElement | null = null; private selectedFile: TFile | null = null; private fileLabel: HTMLSpanElement | null = null; @@ -28,13 +30,15 @@ export class TaskEditorModal extends Modal { task: Task, taskFiles: TFile[], currentFile: TFile, - onSave: SaveCallback + onSave: SaveCallback, + onDelete: DeleteCallback ) { super(app); this.task = task; this.taskFiles = taskFiles; this.currentFile = currentFile; this.onSave = onSave; + this.onDelete = onDelete; } onOpen(): void { @@ -44,8 +48,11 @@ export class TaskEditorModal extends Modal { // Title contentEl.createEl('h2', { text: 'Edit task' }); - // Textarea: first line = task text, rest = notes - const initialValue = [this.task.text, ...this.task.notes].join('\n'); + // Textarea: first line = task text, blank line, then notes + const initialValue = + this.task.notes.length > 0 + ? [this.task.text, '', ...this.task.notes].join('\n') + : this.task.text; this.textarea = contentEl.createEl('textarea', { cls: 'yaotp-editor-textarea', attr: { rows: '8', placeholder: 'Task title\n\nNotes…' }, @@ -81,6 +88,18 @@ export class TaskEditorModal extends Modal { // Buttons const btnRow = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' }); + + const deleteBtn = btnRow.createEl('button', { + text: 'Delete', + cls: 'yaotp-editor-delete-btn', + }); + deleteBtn.addEventListener('click', () => { + new ConfirmModal(this.app, 'Delete this task? This cannot be undone.', () => { + this.close(); + this.onDelete(); + }).open(); + }); + const saveBtn = btnRow.createEl('button', { text: 'Save', cls: 'mod-cta yaotp-editor-save-btn', @@ -100,7 +119,7 @@ export class TaskEditorModal extends Modal { const lines = this.textarea.value.split('\n'); const text = lines[0] ?? ''; const notes = lines.slice(1); - // Trim leading blank lines from notes + // Trim leading blank lines from notes (user may have removed or added the blank separator) while (notes.length > 0 && notes[0].trim() === '') notes.shift(); this.onSave({ text, @@ -111,6 +130,37 @@ export class TaskEditorModal extends Modal { } } +class ConfirmModal extends Modal { + private message: string; + private onConfirm: () => void; + + constructor(app: App, message: string, onConfirm: () => void) { + super(app); + this.message = message; + this.onConfirm = onConfirm; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.createEl('p', { text: this.message }); + const row = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' }); + const confirmBtn = row.createEl('button', { + text: 'Delete', + cls: 'mod-warning yaotp-editor-delete-btn', + }); + confirmBtn.addEventListener('click', () => { + this.onConfirm(); + this.close(); + }); + const cancelBtn = row.createEl('button', { text: 'Cancel' }); + cancelBtn.addEventListener('click', () => this.close()); + } + + onClose(): void { + this.contentEl.empty(); + } +} + class TaskFilePicker extends FuzzySuggestModal { private files: TFile[]; private onSelect: (file: TFile) => void; diff --git a/src/view/TaskFileView.ts b/src/view/TaskFileView.ts index 1727364..caf6e0e 100644 --- a/src/view/TaskFileView.ts +++ b/src/view/TaskFileView.ts @@ -5,11 +5,13 @@ import { TaskListComponent } from './TaskListComponent'; import { buildFileSwitcherBar } from './FileSwitcherBar'; import { TaskEditorModal } from './TaskEditorModal'; import { VIEW_ICON, VIEW_TYPE } from '../constants'; +import { allowMarkdownOnce } from '../file-intercept'; import type { Task } from '../types'; import type { YaotpSettings } from '../settings'; export class TaskFileView extends TextFileView { private tasks: Task[] = []; + private fileHeader: string = ''; private taskList: TaskListComponent | null = null; private rootEl: HTMLElement | null = null; private listEl: HTMLElement | null = null; @@ -40,16 +42,19 @@ export class TaskFileView extends TextFileView { // Called by Obsidian when the file content is loaded or changes externally. setViewData(data: string, _clear: boolean): void { - this.tasks = parseTaskFile(data); + const { header, tasks } = parseTaskFile(data); + this.fileHeader = header; + this.tasks = tasks; this.renderView(); } // Called by Obsidian when it needs to read back the file content. getViewData(): string { - return serializeTaskFile(this.tasks); + return serializeTaskFile({ header: this.fileHeader, tasks: this.tasks }); } clear(): void { + this.fileHeader = ''; this.tasks = []; this.renderView(); } @@ -58,6 +63,24 @@ export class TaskFileView extends TextFileView { async onOpen(): Promise { this.contentEl.addClass('yaotp-view'); this.rootEl = this.contentEl; + + this.addAction('pencil', 'Edit source', async () => { + if (!this.file) return; + const filePath = this.file.path; + // First call: bypass the intercept to land in markdown (Obsidian may + // open in Live Preview regardless of the mode we pass here). + allowMarkdownOnce(this.leaf); + await this.leaf.setViewState({ + type: 'markdown', + state: { file: filePath }, + }); + // Second call: now the leaf is markdown + same file, so the intercept + // treats this as a mode change and lets it through. + await this.leaf.setViewState({ + type: 'markdown', + state: { file: filePath, mode: 'source' }, + }); + }); } async onClose(): Promise { @@ -100,7 +123,16 @@ export class TaskFileView extends TextFileView { private async persistTasks(): Promise { if (!this.file) return; - await this.app.vault.modify(this.file, serializeTaskFile(this.tasks)); + await this.app.vault.modify( + this.file, + serializeTaskFile({ header: this.fileHeader, tasks: this.tasks }) + ); + } + + private async addTask(text: string): Promise { + this.tasks.unshift({ text, notes: [], completed: false }); + await this.persistTasks(); + this.renderView(); } private async completeTask(index: number): Promise { @@ -142,18 +174,17 @@ export class TaskFileView extends TextFileView { await this.persistTasks(); } + this.renderView(); + }, + async () => { + this.tasks.splice(index, 1); + await this.persistTasks(); this.renderView(); } ); modal.open(); } - private async addTask(text: string): Promise { - this.tasks.unshift({ text, notes: [], completed: false }); - await this.persistTasks(); - this.renderView(); - } - private reorderTask(oldIndex: number, newIndex: number): void { const moved = this.tasks.splice(oldIndex, 1)[0]; this.tasks.splice(newIndex, 0, moved); @@ -163,9 +194,9 @@ export class TaskFileView extends TextFileView { private async appendTaskToFile(task: Task, file: TFile): Promise { const content = await this.app.vault.read(file); - const existing = parseTaskFile(content); - existing.push(task); - await this.app.vault.modify(file, serializeTaskFile(existing)); + const data = parseTaskFile(content); + data.tasks.push(task); + await this.app.vault.modify(file, serializeTaskFile(data)); } private openFile(file: TFile): void { @@ -173,11 +204,8 @@ export class TaskFileView extends TextFileView { } private async createAndOpenFile(name: string): Promise { - // Derive folder from inboxPath - const inboxFolder = this.settings.inboxPath.includes('/') - ? this.settings.inboxPath.slice(0, this.settings.inboxPath.lastIndexOf('/')) - : ''; - const path = inboxFolder ? `${inboxFolder}/${name}.md` : `${name}.md`; + const folder = this.settings.taskFileFolder; + const path = folder ? `${folder}/${name}.md` : `${name}.md`; let file = this.app.vault.getAbstractFileByPath(path) as TFile | null; if (!file) { diff --git a/styles.css b/styles.css index e89e064..04e0693 100644 --- a/styles.css +++ b/styles.css @@ -44,6 +44,36 @@ cursor: pointer; } +.yaotp-switcher-create-group { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.yaotp-switcher-name-input { + font-size: var(--font-ui-small); + padding: 3px 6px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-normal); + width: 160px; +} + +.yaotp-switcher-name-input:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.yaotp-switcher-confirm-btn, +.yaotp-switcher-cancel-btn { + flex-shrink: 0; + font-size: var(--font-ui-small); + padding: 3px 8px; + cursor: pointer; +} + /* ── Task List ──────────────────────────────────────────────────────────── */ .yaotp-list-container { @@ -225,6 +255,12 @@ gap: 8px; } +.yaotp-editor-delete-btn { + margin-right: auto; + color: var(--text-error); +} + + /* Settings error state */ .yaotp-setting-error { border-color: var(--color-red) !important;