fix: handle filename collisions in create flows
This commit is contained in:
@@ -139,6 +139,22 @@ mod tests {
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_note_content_rejects_traversal_outside_active_vault() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path();
|
||||
let escape_path = vault_path.join("../outside.md");
|
||||
|
||||
let err = create_note_content(
|
||||
escape_path,
|
||||
"# Outside\n".to_string(),
|
||||
vault_path_arg(vault_path),
|
||||
)
|
||||
.expect_err("expected traversal create to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_folder_rejects_escape_path() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -39,6 +39,22 @@ fn with_requested_root_path<T>(
|
||||
with_requested_root(raw_vault_path.as_ref(), action)
|
||||
}
|
||||
|
||||
fn with_writable_note_path<T>(
|
||||
path: PathBuf,
|
||||
vault_path: Option<PathBuf>,
|
||||
action: impl FnOnce(&str) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
with_validated_path(
|
||||
path.to_string_lossy().as_ref(),
|
||||
vault_path
|
||||
.as_ref()
|
||||
.map(|value| value.to_string_lossy())
|
||||
.as_deref(),
|
||||
ValidatedPathMode::Writable,
|
||||
action,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_note_content(path: PathBuf, vault_path: Option<PathBuf>) -> Result<String, String> {
|
||||
with_note_path(
|
||||
@@ -55,15 +71,20 @@ pub fn save_note_content(
|
||||
content: String,
|
||||
vault_path: Option<PathBuf>,
|
||||
) -> Result<(), String> {
|
||||
with_validated_path(
|
||||
path.to_string_lossy().as_ref(),
|
||||
vault_path
|
||||
.as_ref()
|
||||
.map(|value| value.to_string_lossy())
|
||||
.as_deref(),
|
||||
ValidatedPathMode::Writable,
|
||||
|validated_path| vault::save_note_content(validated_path, &content),
|
||||
)
|
||||
with_writable_note_path(path, vault_path, |validated_path| {
|
||||
vault::save_note_content(validated_path, &content)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_note_content(
|
||||
path: PathBuf,
|
||||
content: String,
|
||||
vault_path: Option<PathBuf>,
|
||||
) -> Result<(), String> {
|
||||
with_writable_note_path(path, vault_path, |validated_path| {
|
||||
vault::create_note_content(validated_path, &content)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -196,6 +196,7 @@ macro_rules! app_invoke_handler {
|
||||
commands::list_vault,
|
||||
commands::list_vault_folders,
|
||||
commands::get_note_content,
|
||||
commands::create_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::fs;
|
||||
use std::io::{ErrorKind, Write};
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
@@ -63,3 +64,25 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
validate_save_path(file_path, path)?;
|
||||
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// Create a new note file without overwriting any existing file.
|
||||
pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
let file_path = Path::new(path);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
}
|
||||
}
|
||||
validate_save_path(file_path, path)?;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(file_path)
|
||||
.map_err(|e| match e.kind() {
|
||||
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
|
||||
_ => format!("Failed to create {}: {}", path, e),
|
||||
})?;
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|e| format!("Failed to save {}: {}", path, e))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub use config_seed::{
|
||||
seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus,
|
||||
};
|
||||
pub use entry::{FolderNode, VaultEntry};
|
||||
pub use file::{get_note_content, save_note_content};
|
||||
pub use file::{create_note_content, get_note_content, save_note_content};
|
||||
pub use folders::{delete_folder, rename_folder, FolderRenameResult};
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
|
||||
@@ -165,3 +165,29 @@ fn test_save_note_content_deeply_nested_new_directory() {
|
||||
assert!(path.exists());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_note_content_creates_parent_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("new-type/briefing.md");
|
||||
let content = "---\ntitle: Briefing\ntype: Note\n---\n";
|
||||
|
||||
assert!(!path.parent().unwrap().exists());
|
||||
create_note_content(path.to_str().unwrap(), content).unwrap();
|
||||
|
||||
assert!(path.exists());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_note_content_rejects_existing_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("briefing.md");
|
||||
fs::write(&path, "# Existing\n").unwrap();
|
||||
|
||||
let err = create_note_content(path.to_str().unwrap(), "# Replacement\n")
|
||||
.expect_err("expected create-only write to reject collisions");
|
||||
|
||||
assert!(err.contains("already exists"));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "# Existing\n");
|
||||
}
|
||||
|
||||
39
src/App.tsx
39
src/App.tsx
@@ -30,7 +30,7 @@ import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
import { slugify } from './hooks/useNoteCreation'
|
||||
import { planNewTypeCreation } from './hooks/useNoteCreation'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
|
||||
import { useViewMode, type ViewMode } from './hooks/useViewMode'
|
||||
@@ -937,37 +937,40 @@ function App() {
|
||||
const shouldLoadGitHistory = !layout.inspectorCollapsed && !showAIChat
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory, shouldLoadGitHistory)
|
||||
|
||||
const handleCreateType = useCallback((name: string) => {
|
||||
notes.handleCreateType(name)
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
const handleCreateType = useCallback(async (name: string) => {
|
||||
const created = await notes.handleCreateType(name)
|
||||
if (created) setToastMessage(`Type "${name}" created`)
|
||||
return created
|
||||
}, [notes, setToastMessage])
|
||||
|
||||
const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => {
|
||||
const trimmed = nextTypeName.trim()
|
||||
if (!trimmed) return
|
||||
if (!trimmed) return false
|
||||
|
||||
const targetFilename = `${slugify(trimmed)}.md`
|
||||
const exactType = vault.entries.find((entry) => entry.isA === 'Type' && entry.title === trimmed)
|
||||
const slugMatch = vault.entries.find((entry) => entry.isA === 'Type' && slugify(entry.title) === slugify(trimmed))
|
||||
const filenameCollision = vault.entries.find((entry) => entry.filename.toLowerCase() === targetFilename)
|
||||
const resolvedTypeName = exactType?.title ?? slugMatch?.title ?? trimmed
|
||||
|
||||
if (filenameCollision && filenameCollision.isA !== 'Type') {
|
||||
setToastMessage(`Cannot create type "${trimmed}" because ${targetFilename} already exists`)
|
||||
throw new Error(`Type filename collision for ${targetFilename}`)
|
||||
const plan = planNewTypeCreation({ entries: vault.entries, typeName: trimmed, vaultPath: resolvedPath })
|
||||
if (plan.status === 'blocked') {
|
||||
setToastMessage(plan.message)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!exactType && !slugMatch) {
|
||||
await notes.createTypeEntrySilent(trimmed)
|
||||
let resolvedTypeName = plan.status === 'existing' ? plan.entry.title : trimmed
|
||||
|
||||
if (plan.status === 'create') {
|
||||
try {
|
||||
resolvedTypeName = (await notes.createTypeEntrySilent(trimmed)).title
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
await notes.handleUpdateFrontmatter(path, 'type', resolvedTypeName)
|
||||
setToastMessage(
|
||||
resolvedTypeName === missingType
|
||||
plan.status === 'create' && resolvedTypeName === missingType
|
||||
? `Type "${resolvedTypeName}" created`
|
||||
: `Type set to "${resolvedTypeName}"`,
|
||||
)
|
||||
}, [notes, setToastMessage, vault.entries])
|
||||
return true
|
||||
}, [notes, resolvedPath, setToastMessage, vault.entries])
|
||||
|
||||
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
|
||||
const editing = dialogs.editingView
|
||||
|
||||
@@ -46,6 +46,19 @@ describe('CreateTypeDialog', () => {
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('stays open when create reports a handled collision', async () => {
|
||||
const onClose = vi.fn()
|
||||
const onCreate = vi.fn().mockResolvedValue(false)
|
||||
render(<CreateTypeDialog open={true} onClose={onClose} onCreate={onCreate} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Recipe, Book, Habit...'), { target: { value: 'Recipe' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledWith('Recipe'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Create New Type')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<CreateTypeDialog open={true} onClose={onClose} onCreate={() => {}} />)
|
||||
|
||||
@@ -6,14 +6,14 @@ import { Input } from '@/components/ui/input'
|
||||
interface CreateTypeDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreate: (name: string) => void | Promise<void>
|
||||
onCreate: (name: string) => boolean | void | Promise<boolean | void>
|
||||
initialName?: string
|
||||
}
|
||||
|
||||
interface CreateTypeDialogFormProps {
|
||||
initialName: string
|
||||
onClose: () => void
|
||||
onCreate: (name: string) => void | Promise<void>
|
||||
onCreate: (name: string) => boolean | void | Promise<boolean | void>
|
||||
}
|
||||
|
||||
function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDialogFormProps) {
|
||||
@@ -23,8 +23,8 @@ function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDial
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
await onCreate(trimmed)
|
||||
onClose()
|
||||
const created = await onCreate(trimmed)
|
||||
if (created !== false) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -239,7 +239,7 @@ export function DynamicPropertiesPanel({
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
|
||||
}) {
|
||||
const {
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
|
||||
@@ -52,7 +52,7 @@ interface EditorProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
showAIChat?: boolean
|
||||
@@ -360,7 +360,7 @@ function EditorLayout({
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
|
||||
@@ -27,7 +27,7 @@ interface EditorRightPanelProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onToggleRawEditor?: () => void
|
||||
@@ -110,7 +110,7 @@ export function EditorRightPanel({
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
onCreateMissingType={onCreateMissingType as ((path: string, missingType: string, nextTypeName: string) => Promise<void>) | undefined}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onToggleRawEditor={onToggleRawEditor}
|
||||
|
||||
@@ -118,7 +118,7 @@ function MissingTypeWarning({
|
||||
onCreateMissingType,
|
||||
}: {
|
||||
missingTypeName: string
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const canCreateMissingType = Boolean(onCreateMissingType)
|
||||
@@ -148,9 +148,7 @@ function MissingTypeWarning({
|
||||
<CreateTypeDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onCreate={async (typeName) => {
|
||||
await onCreateMissingType?.(typeName)
|
||||
}}
|
||||
onCreate={(typeName) => onCreateMissingType?.(typeName)}
|
||||
initialName={missingTypeName}
|
||||
/>
|
||||
)}
|
||||
@@ -165,7 +163,7 @@ function TypeRowValue({
|
||||
}: {
|
||||
children: ReactNode
|
||||
missingTypeName?: string | null
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-start gap-1">
|
||||
@@ -191,7 +189,7 @@ function ReadOnlyType({
|
||||
customColorKey?: string | null
|
||||
onNavigate?: (target: string) => void
|
||||
missingTypeName?: string | null
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
|
||||
}) {
|
||||
if (!isA) return null
|
||||
return (
|
||||
@@ -230,7 +228,7 @@ interface TypeSelectorProps {
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
missingTypeName?: string | null
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
|
||||
}
|
||||
|
||||
export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps) {
|
||||
|
||||
@@ -8,7 +8,7 @@ interface InspectorPropertyActionsConfig {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
|
||||
}
|
||||
|
||||
function bindEntryAction<TArgs extends unknown[], TResult>(
|
||||
@@ -21,7 +21,7 @@ function bindEntryAction<TArgs extends unknown[], TResult>(
|
||||
|
||||
function bindMissingTypeAction(
|
||||
entry: VaultEntry | null,
|
||||
action: ((path: string, missingType: string, nextTypeName: string) => Promise<void>) | undefined,
|
||||
action: ((path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>) | undefined,
|
||||
) {
|
||||
const missingType = entry?.isA
|
||||
if (!entry || !missingType || !action) return undefined
|
||||
|
||||
@@ -70,18 +70,48 @@ describe('useNoteActions hook', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('handleCreateNote calls addEntry and creates correct entry', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
function renderActions(entries: VaultEntry[] = []) {
|
||||
return renderHook(() => useNoteActions(makeConfig(entries)))
|
||||
}
|
||||
|
||||
function createImmediateEntry(type?: string) {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
const { result } = renderActions()
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate(type)
|
||||
})
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
vi.restoreAllMocks()
|
||||
return createdEntry as VaultEntry
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'handleCreateNote',
|
||||
run: (result: ReturnType<typeof renderActions>['result']) => result.current.handleCreateNote('Test Note', 'Note'),
|
||||
expectedTitle: 'Test Note',
|
||||
expectedType: 'Note',
|
||||
expectedPathFragment: 'test-note.md',
|
||||
},
|
||||
{
|
||||
name: 'handleCreateType',
|
||||
run: (result: ReturnType<typeof renderActions>['result']) => result.current.handleCreateType('Recipe'),
|
||||
expectedTitle: 'Recipe',
|
||||
expectedType: 'Type',
|
||||
expectedPathFragment: 'recipe.md',
|
||||
},
|
||||
])('$name creates the expected entry', ({ run, expectedTitle, expectedType, expectedPathFragment }) => {
|
||||
const { result } = renderActions()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNote('Test Note', 'Note')
|
||||
run(result)
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
expect(createdEntry.title).toBe('Test Note')
|
||||
expect(createdEntry.isA).toBe('Note')
|
||||
expect(createdEntry.path).toContain('test-note.md')
|
||||
expect(createdEntry.title).toBe(expectedTitle)
|
||||
expect(createdEntry.isA).toBe(expectedType)
|
||||
expect(createdEntry.path).toContain(expectedPathFragment)
|
||||
})
|
||||
|
||||
it('handleCreateNote opens tab immediately (before addEntry resolves)', () => {
|
||||
@@ -102,19 +132,6 @@ describe('useNoteActions hook', () => {
|
||||
expect(result.current.activeTabPath).toContain('fast-note.md')
|
||||
})
|
||||
|
||||
it('handleCreateType creates type entry', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateType('Recipe')
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
expect(createdEntry.isA).toBe('Type')
|
||||
expect(createdEntry.title).toBe('Recipe')
|
||||
})
|
||||
|
||||
it('handleNavigateWikilink finds entry by title', async () => {
|
||||
const target = makeEntry({ title: 'Target Note', path: '/vault/target.md' })
|
||||
|
||||
@@ -178,19 +195,10 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate creates note with timestamp-based title', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate()
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
const createdEntry = createImmediateEntry()
|
||||
expect(createdEntry.title).toBe('Untitled Note 1700000000')
|
||||
expect(createdEntry.filename).toBe('untitled-note-1700000000.md')
|
||||
expect(createdEntry.isA).toBe('Note')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
|
||||
@@ -217,18 +225,9 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate accepts custom type', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('Project')
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
const createdEntry = createImmediateEntry('Project')
|
||||
expect(createdEntry.filename).toMatch(/^untitled-project-\d+\.md$/)
|
||||
expect(createdEntry.isA).toBe('Project')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('handleCreateNote uses default template for Project type', () => {
|
||||
@@ -435,7 +434,11 @@ describe('useNoteActions hook', () => {
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining(pathFragment))
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
|
||||
expect(setToastMessage).toHaveBeenCalledWith(
|
||||
type === 'Type'
|
||||
? 'Failed to create type — disk write error'
|
||||
: 'Failed to create note — disk write error',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not revert when disk write succeeds', async () => {
|
||||
|
||||
@@ -348,6 +348,21 @@ describe('useNoteCreation hook', () => {
|
||||
expect(addEntry.mock.calls[0][0].title).toBe('Recipe')
|
||||
})
|
||||
|
||||
it('handleCreateType blocks when a non-type file already uses the target filename', async () => {
|
||||
const existing = makeEntry({ path: '/test/vault/briefing.md', filename: 'briefing.md', title: 'Briefing', isA: 'Note' })
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps))
|
||||
|
||||
let created = true
|
||||
await act(async () => {
|
||||
created = await result.current.handleCreateType('Briefing')
|
||||
})
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(addEntry).not.toHaveBeenCalled()
|
||||
expect(openTabWithContent).not.toHaveBeenCalled()
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "Briefing" because briefing.md already exists')
|
||||
})
|
||||
|
||||
it('createTypeEntrySilent persists without opening tab', async () => {
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
const entry = await act(async () => result.current.createTypeEntrySilent('Recipe'))
|
||||
@@ -356,6 +371,32 @@ describe('useNoteCreation hook', () => {
|
||||
expect(entry.isA).toBe('Type')
|
||||
})
|
||||
|
||||
it('createTypeEntrySilent reuses an existing slug-equivalent type', async () => {
|
||||
const existing = makeEntry({ path: '/test/vault/briefing.md', filename: 'briefing.md', title: 'Briefing', isA: 'Type' })
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps))
|
||||
|
||||
const entry = await act(async () => result.current.createTypeEntrySilent('briefing'))
|
||||
|
||||
expect(entry).toBe(existing)
|
||||
expect(addEntry).not.toHaveBeenCalled()
|
||||
expect(openTabWithContent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleCreateNoteForRelationship blocks when the generated filename already exists', async () => {
|
||||
const existing = makeEntry({ path: '/test/vault/briefing.md', filename: 'briefing.md', title: 'Existing Briefing', isA: 'Note' })
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps))
|
||||
|
||||
let created = true
|
||||
await act(async () => {
|
||||
created = await result.current.handleCreateNoteForRelationship('Briefing')
|
||||
})
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(addEntry).not.toHaveBeenCalled()
|
||||
expect(openTabWithContent).not.toHaveBeenCalled()
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Cannot create note "Briefing" because briefing.md already exists')
|
||||
})
|
||||
|
||||
it('reverts optimistic creation when disk write fails (Tauri)', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
|
||||
|
||||
@@ -132,10 +132,104 @@ export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry:
|
||||
return { entry, content: `---\ntype: Type\n---\n` }
|
||||
}
|
||||
|
||||
type ResolvedEntry = { entry: VaultEntry; content: string }
|
||||
|
||||
interface BlockedCreationPlan {
|
||||
status: 'blocked'
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ReadyCreationPlan {
|
||||
status: 'create'
|
||||
resolved: ResolvedEntry
|
||||
}
|
||||
|
||||
interface ExistingTypeCreationPlan {
|
||||
status: 'existing'
|
||||
entry: VaultEntry
|
||||
}
|
||||
|
||||
export type NoteCreationPlan = BlockedCreationPlan | ReadyCreationPlan
|
||||
export type TypeCreationPlan = BlockedCreationPlan | ExistingTypeCreationPlan | ReadyCreationPlan
|
||||
|
||||
function normalizeComparablePath(path: string): string {
|
||||
return path.replace(/\\/g, '/').toLocaleLowerCase()
|
||||
}
|
||||
|
||||
function findPathCollision(entries: VaultEntry[], path: string): VaultEntry | undefined {
|
||||
const target = normalizeComparablePath(path)
|
||||
return entries.find((entry) => normalizeComparablePath(entry.path) === target)
|
||||
}
|
||||
|
||||
function buildCreationCollisionMessage({ noun, title, path }: { noun: 'note' | 'type'; title: string; path: string }): string {
|
||||
const filename = path.split('/').pop() ?? path
|
||||
return `Cannot create ${noun} "${title}" because ${filename} already exists`
|
||||
}
|
||||
|
||||
function findEquivalentTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
|
||||
const trimmed = typeName.trim()
|
||||
const targetSlug = slugify(trimmed)
|
||||
return entries.find((entry) =>
|
||||
entry.isA === 'Type' && (entry.title === trimmed || slugify(entry.title) === targetSlug)
|
||||
)
|
||||
}
|
||||
|
||||
export function planNewNoteCreation({
|
||||
entries,
|
||||
title,
|
||||
type,
|
||||
vaultPath,
|
||||
template,
|
||||
}: NewNoteParams & { entries: VaultEntry[] }): NoteCreationPlan {
|
||||
const resolved = resolveNewNote({ title, type, vaultPath, template })
|
||||
const collision = findPathCollision(entries, resolved.entry.path)
|
||||
if (collision) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
message: buildCreationCollisionMessage({ noun: 'note', title, path: resolved.entry.path }),
|
||||
}
|
||||
}
|
||||
return { status: 'create', resolved }
|
||||
}
|
||||
|
||||
export function planNewTypeCreation({
|
||||
entries,
|
||||
typeName,
|
||||
vaultPath,
|
||||
}: NewTypeParams & { entries: VaultEntry[] }): TypeCreationPlan {
|
||||
const existingType = findEquivalentTypeEntry(entries, typeName)
|
||||
if (existingType) return { status: 'existing', entry: existingType }
|
||||
|
||||
const resolved = resolveNewType({ typeName, vaultPath })
|
||||
const collision = findPathCollision(entries, resolved.entry.path)
|
||||
if (collision) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
message: buildCreationCollisionMessage({ noun: 'type', title: typeName, path: resolved.entry.path }),
|
||||
}
|
||||
}
|
||||
return { status: 'create', resolved }
|
||||
}
|
||||
|
||||
function isAlreadyExistsError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return /already exists|file exists|eexist/i.test(message)
|
||||
}
|
||||
|
||||
function createPersistFailureMessage(entry: VaultEntry, error: unknown): string {
|
||||
if (isAlreadyExistsError(error)) {
|
||||
const noun = entry.isA === 'Type' ? 'type' : 'note'
|
||||
return buildCreationCollisionMessage({ noun, title: entry.title, path: entry.path })
|
||||
}
|
||||
return entry.isA === 'Type'
|
||||
? 'Failed to create type — disk write error'
|
||||
: 'Failed to create note — disk write error'
|
||||
}
|
||||
|
||||
/** Persist a newly created note to disk. Returns a Promise for error handling. */
|
||||
export function persistNewNote(path: string, content: string): Promise<void> {
|
||||
if (!isTauri()) return Promise.resolve()
|
||||
return invoke<void>('save_note_content', { path, content }).then(() => {})
|
||||
return invoke<void>('create_note_content', { path, content }).then(() => {})
|
||||
}
|
||||
|
||||
// Rapid Cmd+N bursts can outpace the note-list render path on desktop. Keep
|
||||
@@ -156,33 +250,125 @@ function signalFocusEditor(opts?: { selectTitle?: boolean; path?: string }): voi
|
||||
}
|
||||
|
||||
interface PersistCallbacks {
|
||||
onFail: (p: string) => void
|
||||
onStart?: (p: string) => void
|
||||
onEnd?: (p: string) => void
|
||||
onPersisted?: () => void
|
||||
}
|
||||
|
||||
/** Persist to disk; track pending state via onStart/onEnd; revert on failure. */
|
||||
function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): void {
|
||||
/** Persist to disk; track pending state via onStart/onEnd. */
|
||||
async function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): Promise<void> {
|
||||
cbs.onStart?.(path)
|
||||
persistNewNote(path, content)
|
||||
.then(() => { cbs.onEnd?.(path); cbs.onPersisted?.() })
|
||||
.catch(() => { cbs.onEnd?.(path); cbs.onFail(path) })
|
||||
try {
|
||||
await persistNewNote(path, content)
|
||||
cbs.onPersisted?.()
|
||||
} finally {
|
||||
cbs.onEnd?.(path)
|
||||
}
|
||||
}
|
||||
|
||||
type ResolvedNote = { entry: VaultEntry; content: string }
|
||||
type PersistFn = (resolved: ResolvedNote) => void
|
||||
interface PersistResolvedOptions {
|
||||
openTab?: boolean
|
||||
}
|
||||
|
||||
/** Optimistically open note, add entry to vault, and persist to disk. */
|
||||
function createAndPersist(
|
||||
resolved: ResolvedNote,
|
||||
addFn: (e: VaultEntry) => void,
|
||||
openTab: (e: VaultEntry, c: string) => void,
|
||||
cbs: PersistCallbacks,
|
||||
): void {
|
||||
openTab(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addFn)
|
||||
persistOptimistic(resolved.entry.path, resolved.content, cbs)
|
||||
type PersistResolvedEntryFn = (
|
||||
resolved: ResolvedEntry,
|
||||
options?: PersistResolvedOptions,
|
||||
) => Promise<void>
|
||||
|
||||
interface CreationDeps {
|
||||
entries: VaultEntry[]
|
||||
vaultPath: string
|
||||
setToastMessage: (msg: string | null) => void
|
||||
persistResolvedEntry: PersistResolvedEntryFn
|
||||
}
|
||||
|
||||
interface NoteCreationRequest extends CreationDeps {
|
||||
title: string
|
||||
type: string
|
||||
creationPath?: 'plus_button'
|
||||
}
|
||||
|
||||
async function createNamedNote({
|
||||
entries,
|
||||
title,
|
||||
type,
|
||||
vaultPath,
|
||||
setToastMessage,
|
||||
persistResolvedEntry,
|
||||
creationPath,
|
||||
}: NoteCreationRequest): Promise<boolean> {
|
||||
const template = resolveTemplate({ entries, typeName: type })
|
||||
const plan = planNewNoteCreation({ entries, title, type, vaultPath, template })
|
||||
if (plan.status === 'blocked') {
|
||||
setToastMessage(plan.message)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await persistResolvedEntry(plan.resolved)
|
||||
if (creationPath) {
|
||||
trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: creationPath })
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
setToastMessage(createPersistFailureMessage(plan.resolved.entry, error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface TypeCreationRequest extends CreationDeps {
|
||||
typeName: string
|
||||
}
|
||||
|
||||
async function createTypeFromName({
|
||||
entries,
|
||||
typeName,
|
||||
vaultPath,
|
||||
setToastMessage,
|
||||
persistResolvedEntry,
|
||||
}: TypeCreationRequest): Promise<boolean> {
|
||||
const plan = planNewTypeCreation({ entries, typeName, vaultPath })
|
||||
if (plan.status === 'existing') {
|
||||
setToastMessage(`Type "${plan.entry.title}" already exists`)
|
||||
return false
|
||||
}
|
||||
if (plan.status === 'blocked') {
|
||||
setToastMessage(plan.message)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await persistResolvedEntry(plan.resolved)
|
||||
trackEvent('type_created')
|
||||
return true
|
||||
} catch (error) {
|
||||
setToastMessage(createPersistFailureMessage(plan.resolved.entry, error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function createTypeSilently({
|
||||
entries,
|
||||
typeName,
|
||||
vaultPath,
|
||||
setToastMessage,
|
||||
persistResolvedEntry,
|
||||
}: TypeCreationRequest): Promise<VaultEntry> {
|
||||
const plan = planNewTypeCreation({ entries, typeName, vaultPath })
|
||||
if (plan.status === 'existing') return plan.entry
|
||||
if (plan.status === 'blocked') {
|
||||
setToastMessage(plan.message)
|
||||
throw new Error(plan.message)
|
||||
}
|
||||
|
||||
try {
|
||||
await persistResolvedEntry(plan.resolved, { openTab: false })
|
||||
return plan.resolved.entry
|
||||
} catch (error) {
|
||||
const message = createPersistFailureMessage(plan.resolved.entry, error)
|
||||
setToastMessage(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
interface ImmediateCreateDeps {
|
||||
@@ -318,30 +504,6 @@ function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: st
|
||||
}, [syncDeps, executeRequest, scheduleQueuedBurst])
|
||||
}
|
||||
|
||||
interface RelationshipCreateDeps {
|
||||
entries: VaultEntry[]
|
||||
vaultPath: string
|
||||
openTabWithContent: (entry: VaultEntry, content: string) => void
|
||||
addEntry: (entry: VaultEntry) => void
|
||||
removeEntry: (path: string) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onNewNotePersisted?: () => void
|
||||
}
|
||||
|
||||
/** Create a note for a relationship link; persist in background. */
|
||||
function createNoteForRelationship(deps: RelationshipCreateDeps, title: string): void {
|
||||
const template = resolveTemplate({ entries: deps.entries, typeName: 'Note' })
|
||||
const resolved = resolveNewNote({ title, type: 'Note', vaultPath: deps.vaultPath, template })
|
||||
deps.openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
|
||||
persistNewNote(resolved.entry.path, resolved.content)
|
||||
.then(() => deps.onNewNotePersisted?.())
|
||||
.catch(() => {
|
||||
deps.removeEntry(resolved.entry.path)
|
||||
deps.setToastMessage('Failed to create note — disk write error')
|
||||
})
|
||||
}
|
||||
|
||||
export interface NoteCreationConfig {
|
||||
addEntry: (entry: VaultEntry) => void
|
||||
removeEntry: (path: string) => void
|
||||
@@ -362,60 +524,59 @@ interface CreationTabDeps {
|
||||
}
|
||||
|
||||
export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTabDeps) {
|
||||
const { addEntry, removeEntry, entries, setToastMessage, addPendingSave, removePendingSave } = config
|
||||
const { addEntry, removeEntry, entries, setToastMessage, addPendingSave, removePendingSave, vaultPath } = config
|
||||
const { openTabWithContent } = tabDeps
|
||||
|
||||
const revertOptimisticNote = useCallback((path: string) => {
|
||||
removeEntry(path)
|
||||
setToastMessage('Failed to create note — disk write error')
|
||||
}, [removeEntry, setToastMessage])
|
||||
const persistResolvedEntry = useCallback(async (
|
||||
resolved: ResolvedEntry,
|
||||
options?: PersistResolvedOptions,
|
||||
): Promise<void> => {
|
||||
if (options?.openTab !== false) openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
try {
|
||||
await persistOptimistic(resolved.entry.path, resolved.content, {
|
||||
onStart: addPendingSave,
|
||||
onEnd: removePendingSave,
|
||||
onPersisted: config.onNewNotePersisted,
|
||||
})
|
||||
} catch (error) {
|
||||
removeEntry(resolved.entry.path)
|
||||
throw error
|
||||
}
|
||||
}, [openTabWithContent, addEntry, addPendingSave, removePendingSave, config.onNewNotePersisted, removeEntry])
|
||||
|
||||
const persistNew: PersistFn = useCallback(
|
||||
(resolved) => createAndPersist(resolved, addEntry, openTabWithContent, {
|
||||
onFail: revertOptimisticNote,
|
||||
onStart: addPendingSave,
|
||||
onEnd: removePendingSave,
|
||||
onPersisted: config.onNewNotePersisted,
|
||||
}),
|
||||
[openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave, config.onNewNotePersisted],
|
||||
)
|
||||
const creationDeps = {
|
||||
entries,
|
||||
vaultPath,
|
||||
setToastMessage,
|
||||
persistResolvedEntry,
|
||||
}
|
||||
|
||||
const handleCreateNote = useCallback((title: string, type: string): Promise<boolean> =>
|
||||
createNamedNote({ ...creationDeps, title, type, creationPath: 'plus_button' }),
|
||||
[creationDeps])
|
||||
|
||||
const handleCreateType = useCallback((typeName: string): Promise<boolean> =>
|
||||
createTypeFromName({ ...creationDeps, typeName }),
|
||||
[creationDeps])
|
||||
|
||||
const createTypeEntrySilent = useCallback((typeName: string): Promise<VaultEntry> =>
|
||||
createTypeSilently({ ...creationDeps, typeName }),
|
||||
[creationDeps])
|
||||
|
||||
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> =>
|
||||
createNamedNote({ ...creationDeps, title, type: 'Note' }),
|
||||
[creationDeps])
|
||||
|
||||
const handleCreateNoteImmediate = useImmediateCreateQueue({
|
||||
entries,
|
||||
vaultPath: config.vaultPath,
|
||||
vaultPath,
|
||||
addEntry,
|
||||
openTabWithContent,
|
||||
trackUnsaved: config.trackUnsaved,
|
||||
markContentPending: config.markContentPending,
|
||||
})
|
||||
|
||||
const handleCreateNote = useCallback((title: string, type: string) => {
|
||||
const template = resolveTemplate({ entries, typeName: type })
|
||||
persistNew(resolveNewNote({ title, type, vaultPath: config.vaultPath, template }))
|
||||
trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: 'plus_button' })
|
||||
}, [entries, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
|
||||
createNoteForRelationship({
|
||||
entries, vaultPath: config.vaultPath, openTabWithContent, addEntry,
|
||||
removeEntry, setToastMessage, onNewNotePersisted: config.onNewNotePersisted,
|
||||
}, title)
|
||||
return Promise.resolve(true)
|
||||
}, [entries, openTabWithContent, addEntry, removeEntry, setToastMessage, config.vaultPath, config.onNewNotePersisted])
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => {
|
||||
persistNew(resolveNewType({ typeName, vaultPath: config.vaultPath }))
|
||||
trackEvent('type_created')
|
||||
}, [persistNew, config.vaultPath])
|
||||
|
||||
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
|
||||
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
|
||||
const resolved = resolveNewType({ typeName, vaultPath: config.vaultPath })
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
await persistNewNote(resolved.entry.path, resolved.content)
|
||||
return resolved.entry
|
||||
}, [addEntry, config.vaultPath])
|
||||
|
||||
return {
|
||||
handleCreateNote,
|
||||
handleCreateNoteImmediate,
|
||||
|
||||
85
tests/smoke/collision-create-flows.spec.ts
Normal file
85
tests/smoke/collision-create-flows.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function writeFixtureNote(vaultPath: string, filename: string, content: string): string {
|
||||
const notePath = path.join(vaultPath, filename)
|
||||
fs.writeFileSync(notePath, content)
|
||||
return notePath
|
||||
}
|
||||
|
||||
function toast(page: import('@playwright/test').Page) {
|
||||
return page.locator('.fixed.bottom-8')
|
||||
}
|
||||
|
||||
test.describe('Collision-safe create flows', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('missing-type creation keeps the dialog open when a root filename already exists', async ({ page }) => {
|
||||
const collidingPath = writeFixtureNote(
|
||||
tempVaultDir,
|
||||
'hotel.md',
|
||||
'---\ntype: Note\n---\n# Existing Hotel Note\n',
|
||||
)
|
||||
writeFixtureNote(
|
||||
tempVaultDir,
|
||||
'hotel-guide.md',
|
||||
'---\ntype: Hotel\nstatus: Active\n---\n# Hotel Guide\n',
|
||||
)
|
||||
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.getByText('Hotel Guide', { exact: true }).click()
|
||||
await sendShortcut(page, 'i', ['Control', 'Shift'])
|
||||
|
||||
const warning = page.getByTestId('missing-type-warning')
|
||||
await expect(warning).toBeVisible()
|
||||
await warning.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByPlaceholder('e.g. Recipe, Book, Habit...')).toHaveValue('Hotel')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(toast(page)).toContainText('Cannot create type "Hotel" because hotel.md already exists')
|
||||
expect(fs.readFileSync(collidingPath, 'utf8')).toContain('# Existing Hotel Note')
|
||||
})
|
||||
|
||||
test('relationship create-and-open keeps the inline input active on filename collision', async ({ page }) => {
|
||||
const collidingPath = writeFixtureNote(
|
||||
tempVaultDir,
|
||||
'briefing.md',
|
||||
'---\ntype: Note\n---\n# Weekly Sync\n',
|
||||
)
|
||||
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.getByText('Team Meeting', { exact: true }).click()
|
||||
await sendShortcut(page, 'i', ['Control', 'Shift'])
|
||||
|
||||
const addButton = page.getByTestId('add-relation-ref').first()
|
||||
await expect(addButton).toBeVisible()
|
||||
await addButton.click()
|
||||
const input = page.getByTestId('add-relation-ref-input')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('Briefing!')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await page.getByTestId('create-and-open-option').click()
|
||||
|
||||
await expect(input).toBeVisible()
|
||||
await expect(toast(page)).toContainText('Cannot create note "Briefing!" because briefing.md already exists')
|
||||
expect(fs.readFileSync(collidingPath, 'utf8')).toContain('# Weekly Sync')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user