feat: enhance folder management by ensuring nested folder paths exist (#1816)

- Make sure nested folder works cross-platform
- Move defaults under copilot parent folder
- Update log file to only create on manual trigger
This commit is contained in:
Logan Yang 2025-09-12 16:30:30 -07:00 committed by GitHub
parent 5a2d4603b4
commit 0d477f869e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 114 additions and 78 deletions

View file

@ -20,6 +20,7 @@ import {
updateCachedCommand,
updateCachedCommands,
} from "./state";
import { ensureFolderExists } from "@/utils";
export class CustomCommandManager {
private static instance: CustomCommandManager;
@ -52,11 +53,8 @@ export class CustomCommandManager {
command = { ...command, order: newOrder };
const folderPath = getCustomCommandsFolder();
// Check if the folder exists and create it if it doesn't
const folderExists = await app.vault.adapter.exists(folderPath);
if (!folderExists) {
await app.vault.createFolder(folderPath);
}
// Ensure nested folders are created cross-platform
await ensureFolderExists(folderPath);
let commandFile = app.vault.getAbstractFileByPath(filePath) as TFile;
if (!commandFile || !(commandFile instanceof TFile)) {

View file

@ -16,7 +16,7 @@ import CopilotPlugin from "@/main";
import { getAllQAMarkdownContent } from "@/search/searchUtils";
import { CopilotSettings, getSettings, updateSetting } from "@/settings/model";
import { SelectedTextContext } from "@/types/message";
import { isSourceModeOn } from "@/utils";
import { ensureFolderExists, isSourceModeOn } from "@/utils";
import { Editor, MarkdownView, Notice, TFile } from "obsidian";
import { v4 as uuidv4 } from "uuid";
import { COMMAND_IDS, COMMAND_NAMES, CommandId } from "../constants";
@ -280,7 +280,11 @@ export function registerCommands(
// Create or update the file in the vault
const fileName = `Copilot-Indexed-Files-${new Date().toLocaleDateString().replace(/\//g, "-")}.md`;
const filePath = `${fileName}`;
const folderPath = "copilot";
const filePath = `${folderPath}/${fileName}`;
// Ensure destination folder exists (supports mobile and nested)
await ensureFolderExists(folderPath);
const existingFile = plugin.app.vault.getAbstractFileByPath(filePath);
if (existingFile) {
@ -326,13 +330,13 @@ export function registerCommands(
}
});
// Open Copilot log file
// Create Copilot log file
addCommand(plugin, COMMAND_IDS.OPEN_LOG_FILE, async () => {
try {
await logFileManager.openLogFile();
} catch (error) {
logError("Error opening Copilot log file:", error);
new Notice("Failed to open Copilot log file.");
logError("Error creating Copilot log file:", error);
new Notice("Failed to create Copilot log file.");
}
});

View file

@ -2,6 +2,7 @@ import { CustomCommandManager } from "@/commands/customCommandManager";
import { getCustomCommandsFolder, validateCommandName } from "@/commands/customCommandUtils";
import { CustomCommand } from "@/commands/type";
import { getSettings, updateSetting } from "@/settings/model";
import { ensureFolderExists } from "@/utils";
import {
COPILOT_COMMAND_CONTEXT_MENU_ORDER,
COPILOT_COMMAND_LAST_USED,
@ -13,13 +14,11 @@ import { COPILOT_COMMAND_CONTEXT_MENU_ENABLED } from "@/commands/constants";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { getCachedCustomCommands } from "@/commands/state";
function saveUnsupportedCommands(commands: CustomCommand[]) {
async function saveUnsupportedCommands(commands: CustomCommand[]) {
const folderPath = getCustomCommandsFolder();
const unsupportedFolderPath = `${folderPath}/unsupported`;
const unsupportedFolder = app.vault.getAbstractFileByPath(unsupportedFolderPath);
if (!unsupportedFolder) {
app.vault.createFolder(unsupportedFolderPath);
}
// Ensure nested structure exists regardless of platform
await ensureFolderExists(unsupportedFolderPath);
return Promise.all(
commands.map(async (command) => {
const filePath = `${unsupportedFolderPath}/${command.title}.md`;

View file

@ -9,6 +9,7 @@ import { Button } from "../ui/button";
import { useState } from "react";
import { getChangeBlocks } from "@/composerUtils";
import { ApplyViewResult } from "@/types";
import { ensureFolderExists } from "@/utils";
export const APPLY_VIEW_TYPE = "obsidian-copilot-apply-view";
@ -185,13 +186,10 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
if (file) {
return file;
}
// Create the folder if it doesn't exist
// Create the folder if it doesn't exist (supports nested paths)
if (file_path.includes("/")) {
const folderPath = file_path.split("/").slice(0, -1).join("/");
const folder = app.vault.getAbstractFileByPath(folderPath);
if (!folder) {
await app.vault.createFolder(folderPath);
}
await ensureFolderExists(folderPath);
}
return await app.vault.create(file_path, "");
};

View file

@ -11,8 +11,8 @@ export const USER_SENDER = "user";
export const AI_SENDER = "ai";
// Default folder names
export const DEFAULT_CHAT_HISTORY_FOLDER = "copilot-conversations";
export const DEFAULT_CUSTOM_PROMPTS_FOLDER = "copilot-custom-prompts";
export const DEFAULT_CHAT_HISTORY_FOLDER = "copilot/copilot-conversations";
export const DEFAULT_CUSTOM_PROMPTS_FOLDER = "copilot/copilot-custom-prompts";
export const DEFAULT_SYSTEM_PROMPT = `You are Obsidian Copilot, a helpful assistant that integrates AI to Obsidian note-taking.
1. Never mention that you do not have access to something. Always rely on the user provided context.
2. Always answer to the best of your knowledge. If you are unsure about something, say so and ask the user to provide more context.
@ -649,7 +649,7 @@ export const COMMAND_NAMES: Record<CommandId, string> = {
[COMMAND_IDS.ADD_SELECTION_TO_CHAT_CONTEXT]: "Add selection to chat context",
[COMMAND_IDS.ADD_CUSTOM_COMMAND]: "Add new custom command",
[COMMAND_IDS.APPLY_CUSTOM_COMMAND]: "Apply custom command",
[COMMAND_IDS.OPEN_LOG_FILE]: "Open log file",
[COMMAND_IDS.OPEN_LOG_FILE]: "Create log file",
[COMMAND_IDS.CLEAR_LOG_FILE]: "Clear log file",
};

View file

@ -27,6 +27,7 @@ jest.mock("@/utils", () => ({
fileName: "20240923_221800",
display: "2024/09/23 22:18:00",
})),
ensureFolderExists: jest.fn(async () => {}),
}));
describe("ChatPersistenceManager", () => {

View file

@ -7,6 +7,7 @@ import { USER_SENDER, AI_SENDER } from "@/constants";
import { getSettings } from "@/settings/model";
import { getCurrentProject } from "@/aiParams";
import ChainManager from "@/LLMProviders/chainManager";
import { ensureFolderExists } from "@/utils";
/**
* ChatPersistenceManager - Handles saving and loading chat messages
@ -39,11 +40,8 @@ export class ChatPersistenceManager {
const chatContent = this.formatChatContent(messages);
const firstMessageEpoch = messages[0].timestamp?.epoch || Date.now();
// Ensure the save folder exists
const folder = this.app.vault.getAbstractFileByPath(settings.defaultSaveFolder);
if (!folder) {
await this.app.vault.createFolder(settings.defaultSaveFolder);
}
// Ensure the save folder exists (supports nested paths) using utility helper.
await ensureFolderExists(settings.defaultSaveFolder);
// Check if a file with this epoch already exists
const existingFile = await this.findFileByEpoch(firstMessageEpoch);

View file

@ -1,11 +1,12 @@
import { err2String } from "@/errorFormat";
import { TFile } from "obsidian";
import { ensureFolderExists } from "@/utils";
type LogLevel = "INFO" | "WARN" | "ERROR";
/**
* Manages a rolling log file that keeps the last N entries and works on desktop and mobile.
* - Writes to <vault>/copilot-log.md
* - Writes to <vault>/copilot/copilot-log.md
* - Maintains an in-memory ring buffer of the last 1000 entries
* - Debounced flush to reduce I/O; single-line entries to preserve accurate line limits
*/
@ -13,12 +14,10 @@ class LogFileManager {
private static instance: LogFileManager;
private readonly maxLines = 1000;
private readonly debounceMs = 1500; // per user preference
private readonly maxLineChars = 8000; // guard against extremely large entries
private buffer: string[] = [];
private initialized = false;
private flushing = false;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
static getInstance(): LogFileManager {
if (!LogFileManager.instance) {
@ -28,7 +27,7 @@ class LogFileManager {
}
getLogPath(): string {
return "copilot-log.md"; // vault root
return "copilot/copilot-log.md"; // under copilot/
}
/** Ensure buffer is loaded with up to last 1000 lines from existing file. */
@ -109,8 +108,8 @@ class LogFileManager {
if (this.buffer.length > this.maxLines) {
this.buffer.splice(0, this.buffer.length - this.maxLines);
}
this.scheduleFlush();
// Intentionally do not flush automatically. We only write to disk when
// the user explicitly opens the log file.
}
/**
@ -137,19 +136,7 @@ class LogFileManager {
this.buffer.splice(0, this.buffer.length - this.maxLines);
}
}
this.scheduleFlush();
}
private scheduleFlush() {
if (!this.hasVault()) return; // no-op in tests or non-Obsidian env
if (this.flushTimer !== null) {
clearTimeout(this.flushTimer);
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
void this.flush();
}, this.debounceMs);
// Intentionally do not flush automatically.
}
async flush(): Promise<void> {
@ -158,8 +145,12 @@ class LogFileManager {
this.flushing = true;
try {
const path = this.getLogPath();
const content = this.buffer.join("\n") + (this.buffer.length ? "\n" : "");
await app.vault.adapter.write(path, content);
// Only write if a log file already exists.
// Do not create files or folders implicitly; creation happens in openLogFile().
if (await app.vault.adapter.exists(path)) {
const content = this.buffer.join("\n") + (this.buffer.length ? "\n" : "");
await app.vault.adapter.write(path, content);
}
} catch {
// swallow write errors; logging should never crash the app
} finally {
@ -189,6 +180,10 @@ class LogFileManager {
try {
if (!file) {
// Create file if missing so it can be opened
const folder = path.includes("/") ? path.split("/").slice(0, -1).join("/") : "";
if (folder) {
await ensureFolderExists(folder);
}
file = await app.vault.create(
path,
this.buffer.join("\n") + (this.buffer.length ? "\n" : "")

View file

@ -365,7 +365,7 @@ export function getExtensionPattern(extension: string): string {
/**
* Get a list of internal Copilot file paths that must be excluded from searches.
* Currently includes the rolling log file path (e.g., "copilot-log.md" at vault root).
* Currently includes the rolling log file path (e.g., "copilot/copilot-log.md").
*/
export function getInternalExcludePaths(): string[] {
return [logFileManager.getLogPath()];

View file

@ -308,6 +308,15 @@ export function sanitizeSettings(settings: CopilotSettings): CopilotSettings {
DEFAULT_SETTINGS.autonomousAgentEnabledToolIds;
}
// Ensure folder settings fall back to defaults when empty/whitespace
const saveFolder = (settingsToSanitize.defaultSaveFolder || "").trim();
sanitizedSettings.defaultSaveFolder =
saveFolder.length > 0 ? saveFolder : DEFAULT_SETTINGS.defaultSaveFolder;
const promptsFolder = (settingsToSanitize.customPromptsFolder || "").trim();
sanitizedSettings.customPromptsFolder =
promptsFolder.length > 0 ? promptsFolder : DEFAULT_SETTINGS.customPromptsFolder;
return sanitizedSettings;
}

View file

@ -1,5 +1,5 @@
import { SettingItem } from "@/components/ui/setting-item";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import { logFileManager } from "@/logFileManager";
import { updateSetting, useSettingsValue } from "@/settings/model";
import React from "react";
@ -43,8 +43,8 @@ export const AdvancedSettings: React.FC = () => {
<SettingItem
type="custom"
title="Share Log"
description="Open the Copilot log file (copilot-log.md) for easy sharing."
title="Create Log File"
description={`Open the Copilot log file (${logFileManager.getLogPath()}) for easy sharing when reporting issues.`}
>
<Button
variant="secondary"
@ -54,7 +54,7 @@ export const AdvancedSettings: React.FC = () => {
await logFileManager.openLogFile();
}}
>
Open Log File
Create Log File
</Button>
</SettingItem>
</div>

View file

@ -246,10 +246,10 @@ export const BasicSettings: React.FC = () => {
<SettingItem
type="text"
title="Default Conversation Folder Name"
description="The default folder name where chat conversations will be saved. Default is 'copilot-conversations'"
description="The default folder name where chat conversations will be saved. Default is 'copilot/copilot-conversations'"
value={settings.defaultSaveFolder}
onChange={(value) => updateSetting("defaultSaveFolder", value)}
placeholder="copilot-conversations"
placeholder="copilot/copilot-conversations"
/>
<SettingItem

View file

@ -1,8 +1,9 @@
import React, { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { useCustomCommands } from "@/commands/state";
import { Lightbulb, GripVertical, Trash2, Plus, Info, PenLine, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Copy, GripVertical, Info, Lightbulb, PenLine, Plus, Trash2 } from "lucide-react";
import React, { useMemo } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
@ -11,15 +12,17 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { PromptSortStrategy } from "@/types";
import {
DndContext,
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
@ -28,25 +31,21 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { useSettingsValue } from "@/settings/model";
import { updateSetting } from "@/settings/model";
import { PromptSortStrategy } from "@/types";
import { Notice } from "obsidian";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { CustomCommand } from "@/commands/type";
import { EMPTY_COMMAND } from "@/commands/constants";
import { CustomCommandManager } from "@/commands/customCommandManager";
import { CustomCommandSettingsModal } from "@/commands/CustomCommandSettingsModal";
import {
generateCopyCommandName,
loadAllCustomCommands,
sortCommandsByOrder,
generateCopyCommandName,
} from "@/commands/customCommandUtils";
import { CustomCommandSettingsModal } from "@/commands/CustomCommandSettingsModal";
import { SettingItem } from "@/components/ui/setting-item";
import { CustomCommandManager } from "@/commands/customCommandManager";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { generateDefaultCommands } from "@/commands/migrator";
import { EMPTY_COMMAND } from "@/commands/constants";
import { CustomCommand } from "@/commands/type";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { SettingItem } from "@/components/ui/setting-item";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Notice } from "obsidian";
const SortableTableRow: React.FC<{
command: CustomCommand;
@ -262,7 +261,7 @@ export const CommandSettings: React.FC = () => {
updateSetting("customPromptsFolder", value);
loadAllCustomCommands();
}}
placeholder="copilot-custom-prompts"
placeholder="copilot/copilot-custom-prompts"
/>
<SettingItem
type="switch"

View file

@ -71,7 +71,8 @@ export const QASettings: React.FC = () => {
<div className="tw-space-y-2">
<div className="tw-flex tw-items-center tw-gap-1.5">
<span className="tw-font-medium tw-leading-none tw-text-accent">
Enable Semantic Search to use it.
Powers Semantic Vault Search and Relevant Notes. Enable Semantic Search to use
it.
</span>
<TooltipProvider delayDuration={0}>
<Tooltip>

View file

@ -18,7 +18,7 @@ import { MemoryVariables } from "@langchain/core/memory";
import { RunnableSequence } from "@langchain/core/runnables";
import { BaseChain, RetrievalQAChain } from "langchain/chains";
import moment from "moment";
import { MarkdownView, Notice, TFile, Vault, requestUrl } from "obsidian";
import { MarkdownView, Notice, TFile, Vault, normalizePath, requestUrl } from "obsidian";
import { CustomModel } from "./aiParams";
export { err2String } from "@/errorFormat";
@ -259,6 +259,40 @@ export const formatDateTime = (
};
};
/**
* Ensure a folder path exists by creating any missing parent directories.
* Works across desktop and mobile. Safe to call repeatedly.
*
* Examples:
* - ensureFolderExists("copilot/copilot-conversations")
* - ensureFolderExists("some/deep/nested/path")
*
* Throws if any segment conflicts with an existing file.
*/
export async function ensureFolderExists(folderPath: string): Promise<void> {
const path = normalizePath(folderPath).replace(/^\/+/, "").replace(/\/+$/, "");
if (!path) return; // nothing to ensure
const parts = path.split("/").filter(Boolean);
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
const existing = app.vault.getAbstractFileByPath(current);
if (existing) {
if (existing instanceof TFile) {
throw new Error(`Path conflict: "${current}" exists as a file, expected folder.`);
}
// If it's a folder, continue to check/create the next segment
continue;
}
// Create this level; parents are guaranteed to exist from previous iterations
await app.vault.adapter.mkdir(current);
}
}
export function stringToFormattedDateTime(timestamp: string): FormattedDateTime {
const date = moment(timestamp, "YYYY/MM/DD HH:mm:ss");
if (!date.isValid()) {