mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
5a2d4603b4
commit
0d477f869e
15 changed files with 114 additions and 78 deletions
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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, "");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ jest.mock("@/utils", () => ({
|
|||
fileName: "20240923_221800",
|
||||
display: "2024/09/23 22:18:00",
|
||||
})),
|
||||
ensureFolderExists: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
describe("ChatPersistenceManager", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" : "")
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
36
src/utils.ts
36
src/utils.ts
|
|
@ -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()) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue