Merge branch 'main' into pr-726
This commit is contained in:
@@ -475,7 +475,7 @@ mod tests {
|
||||
assert_eq!(pull.status, "no_remote");
|
||||
|
||||
let push = git_push(vault.clone()).await.unwrap();
|
||||
assert_eq!(push.status, "error");
|
||||
assert_eq!(push.status, "no_remote");
|
||||
|
||||
let status = git_remote_status(vault.clone()).await.unwrap();
|
||||
assert!(!status.has_remote);
|
||||
|
||||
@@ -192,7 +192,7 @@ mod tests {
|
||||
fn test_create_vault_folder_rejects_escape_path() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let err = create_vault_folder(dir.path().into(), "../escape".into())
|
||||
let err = create_vault_folder(dir.path().into(), "../escape".into(), None)
|
||||
.expect_err("expected escaping folder path to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
@@ -202,12 +202,57 @@ mod tests {
|
||||
fn test_create_vault_folder_rejects_windows_invalid_names() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let err = create_vault_folder(dir.path().into(), "con".into())
|
||||
let err = create_vault_folder(dir.path().into(), "con".into(), None)
|
||||
.expect_err("expected Windows-invalid folder name to be rejected");
|
||||
|
||||
assert_eq!(err, "Invalid folder name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_folder_nests_inside_parent_path() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("Projects")).unwrap();
|
||||
|
||||
let name = create_vault_folder(
|
||||
dir.path().into(),
|
||||
"Laputa".into(),
|
||||
Some(std::path::PathBuf::from("Projects")),
|
||||
)
|
||||
.expect("expected nested folder to be created");
|
||||
|
||||
assert_eq!(name, "Laputa");
|
||||
assert!(dir.path().join("Projects").join("Laputa").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_folder_rejects_escape_via_parent_path() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let err = create_vault_folder(
|
||||
dir.path().into(),
|
||||
"Laputa".into(),
|
||||
Some(std::path::PathBuf::from("../escape")),
|
||||
)
|
||||
.expect_err("expected escaping parent path to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_folder_treats_empty_parent_as_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let name = create_vault_folder(
|
||||
dir.path().into(),
|
||||
"Inbox".into(),
|
||||
Some(std::path::PathBuf::from("")),
|
||||
)
|
||||
.expect("expected empty parent to fall back to vault root");
|
||||
|
||||
assert_eq!(name, "Inbox");
|
||||
assert!(dir.path().join("Inbox").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_view_cmd_rejects_nested_filename() {
|
||||
assert_save_view_cmd_rejects_invalid_filename("../escape.yml");
|
||||
|
||||
@@ -191,11 +191,19 @@ pub fn batch_delete_notes(paths: Vec<PathBuf>) -> Result<Vec<String>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_vault_folder(vault_path: PathBuf, folder_name: PathBuf) -> Result<String, String> {
|
||||
pub fn create_vault_folder(
|
||||
vault_path: PathBuf,
|
||||
folder_name: PathBuf,
|
||||
parent_path: Option<PathBuf>,
|
||||
) -> Result<String, String> {
|
||||
let raw_vault_path = vault_path.to_string_lossy();
|
||||
with_boundary(Some(raw_vault_path.as_ref()), |boundary| {
|
||||
let folder_name = folder_name.to_string_lossy();
|
||||
let folder_path = boundary.child_path(folder_name.as_ref())?;
|
||||
let relative_path = match parent_path.as_deref() {
|
||||
Some(parent) if !parent.as_os_str().is_empty() => parent.join(folder_name.as_ref()),
|
||||
_ => PathBuf::from(folder_name.as_ref()),
|
||||
};
|
||||
let folder_path = boundary.child_path(&relative_path.to_string_lossy())?;
|
||||
validate_folder_name(folder_name.as_ref())?;
|
||||
ensure_missing_folder(&folder_path, folder_name.as_ref())?;
|
||||
std::fs::create_dir_all(&folder_path)
|
||||
@@ -370,7 +378,7 @@ mod tests {
|
||||
fs::write(dir.path().join("root.md"), "# Root\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
create_vault_folder(root.clone(), PathBuf::from("Projects")).unwrap(),
|
||||
create_vault_folder(root.clone(), PathBuf::from("Projects"), None).unwrap(),
|
||||
"Projects"
|
||||
);
|
||||
fs::write(dir.path().join("Projects/project.md"), "# Project\n").unwrap();
|
||||
@@ -394,7 +402,7 @@ mod tests {
|
||||
assert!(error.contains("Path must stay inside the active vault"));
|
||||
|
||||
let folder_error =
|
||||
create_vault_folder(vault.path().to_path_buf(), PathBuf::from("../escape"))
|
||||
create_vault_folder(vault.path().to_path_buf(), PathBuf::from("../escape"), None)
|
||||
.unwrap_err();
|
||||
assert!(folder_error.contains("Path must stay inside the active vault"));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@ use std::path::Path;
|
||||
|
||||
use super::conflict::get_conflict_files;
|
||||
|
||||
const GIT_CONFIG_SUBCOMMAND: &str = "config";
|
||||
const GIT_CONFIG_GET_REGEXP: &str = "--get-regexp";
|
||||
const REMOTE_URL_CONFIG_PATTERN: &str = r"^remote\..*\.url$";
|
||||
const NO_REMOTE_STATUS: &str = "no_remote";
|
||||
const NO_REMOTE_MESSAGE: &str = "No remote configured";
|
||||
const GIT_INSPECT_REMOTES_ERROR: &str = "Failed to inspect git remotes";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPullResult {
|
||||
pub status: String, // "up_to_date" | "updated" | "conflict" | "no_remote" | "error"
|
||||
@@ -17,11 +24,31 @@ pub struct GitPullResult {
|
||||
/// Check whether the vault repo has at least one remote configured.
|
||||
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
remote_url_config_exists(vault)
|
||||
}
|
||||
|
||||
fn remote_url_config_exists(vault: &Path) -> Result<bool, String> {
|
||||
let output = git_command()
|
||||
.args(["remote"])
|
||||
.args([
|
||||
GIT_CONFIG_SUBCOMMAND,
|
||||
GIT_CONFIG_GET_REGEXP,
|
||||
REMOTE_URL_CONFIG_PATTERN,
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git remote: {}", e))?;
|
||||
.map_err(|e| format!("Failed to inspect git remotes: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(false);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if stderr.is_empty() {
|
||||
GIT_INSPECT_REMOTES_ERROR.to_string()
|
||||
} else {
|
||||
stderr
|
||||
});
|
||||
}
|
||||
|
||||
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
|
||||
}
|
||||
@@ -33,8 +60,8 @@ pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
|
||||
|
||||
if !has_remote(vault_path)? {
|
||||
return Ok(GitPullResult {
|
||||
status: "no_remote".to_string(),
|
||||
message: "No remote configured".to_string(),
|
||||
status: NO_REMOTE_STATUS.to_string(),
|
||||
message: NO_REMOTE_MESSAGE.to_string(),
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
});
|
||||
@@ -181,7 +208,7 @@ fn current_branch(vault: &Path) -> Result<String, String> {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPushResult {
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error"
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "no_remote" | "error"
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
@@ -210,6 +237,10 @@ pub fn classify_push_error(stderr: &str) -> GitPushResult {
|
||||
);
|
||||
}
|
||||
|
||||
if is_no_remote_push_error(&lower) {
|
||||
return push_error("no_remote", "No remote configured");
|
||||
}
|
||||
|
||||
push_error(
|
||||
"error",
|
||||
format!("Push failed: {}", push_error_detail(stderr)),
|
||||
@@ -259,6 +290,18 @@ fn is_network_push_error(lower: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_no_remote_push_error(lower: &str) -> bool {
|
||||
contains_any(
|
||||
lower,
|
||||
&[
|
||||
"no configured push destination",
|
||||
"does not appear to be a git repository",
|
||||
"no such remote",
|
||||
"no upstream branch",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn push_error_detail(stderr: &str) -> String {
|
||||
let hint_line = stderr
|
||||
.lines()
|
||||
@@ -283,6 +326,13 @@ fn push_error_detail(stderr: &str) -> String {
|
||||
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !has_remote(vault_path)? {
|
||||
return Ok(GitPushResult {
|
||||
status: NO_REMOTE_STATUS.to_string(),
|
||||
message: NO_REMOTE_MESSAGE.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let output = git_command()
|
||||
.args(["push"])
|
||||
.current_dir(vault)
|
||||
@@ -307,6 +357,11 @@ mod tests {
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use std::fs;
|
||||
|
||||
fn commit_default_note(vault_path: &Path) {
|
||||
fs::write(vault_path.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vault_path.to_str().unwrap(), "initial").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_returns_false_for_local_repo() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -331,6 +386,27 @@ mod tests {
|
||||
assert!(has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_ignores_name_only_remote_without_url() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
git_command()
|
||||
.args(["config", "remote.origin.prune", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let remote_names = git_command()
|
||||
.args(["remote"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&remote_names.stdout).contains("origin"));
|
||||
assert!(!has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_no_remote_returns_no_remote() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -430,6 +506,14 @@ hint: have locally."#;
|
||||
assert!(result.message.contains("network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_no_remote() {
|
||||
let stderr = "fatal: No configured push destination.";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "no_remote");
|
||||
assert!(result.message.contains("No remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_unknown() {
|
||||
let stderr = "error: something unexpected happened\nhint: Try again later";
|
||||
@@ -463,12 +547,23 @@ hint: have locally."#;
|
||||
let (_bare, clone_a, _clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
commit_default_note(clone_a.path());
|
||||
let result = git_push(vp_a).unwrap();
|
||||
assert_eq!(result.status, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_no_remote_returns_no_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
commit_default_note(vault);
|
||||
|
||||
let result = git_push(vp).unwrap();
|
||||
assert_eq!(result.status, "no_remote");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_rejected_returns_rejected() {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@@ -885,12 +885,18 @@ function App() {
|
||||
})
|
||||
}, [updateConfig, vaultConfig.inbox])
|
||||
|
||||
const handleCreateFolder = useCallback(async (name: string) => {
|
||||
const handleCreateFolder = useCallback(async (
|
||||
name: string,
|
||||
parent?: { path: string; rootPath?: string },
|
||||
) => {
|
||||
try {
|
||||
const vaultPath = parent?.rootPath?.trim() ? parent.rootPath : resolvedPath
|
||||
const parentPath = parent?.path && parent.path.length > 0 ? parent.path : null
|
||||
const args = { vaultPath, folderName: name, parentPath }
|
||||
if (isTauri()) {
|
||||
await invoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name })
|
||||
await invoke('create_vault_folder', args)
|
||||
} else {
|
||||
await mockInvoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name })
|
||||
await mockInvoke('create_vault_folder', args)
|
||||
}
|
||||
await vault.reloadFolders()
|
||||
setToastMessage(`Created folder "${name}"`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, type ComponentProps } from 'react'
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { FolderTree } from './FolderTree'
|
||||
@@ -22,6 +22,30 @@ const mockFolders: FolderNode[] = [
|
||||
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
const vaultRootPath = '/Users/luca/Laputa'
|
||||
|
||||
function renderTree(props: Partial<ComponentProps<typeof FolderTree>> = {}) {
|
||||
const onSelect = props.onSelect ?? vi.fn()
|
||||
render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={defaultSelection}
|
||||
onSelect={onSelect}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
return { onSelect }
|
||||
}
|
||||
|
||||
function clickFolderRow(path: string) {
|
||||
fireEvent.click(screen.getByTestId(`folder-row:${path}`))
|
||||
}
|
||||
|
||||
async function submitNewFolder(name: string) {
|
||||
fireEvent.click(screen.getByTestId('create-folder-btn'))
|
||||
const input = screen.getByTestId('new-folder-input')
|
||||
fireEvent.change(input, { target: { value: name } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
}
|
||||
|
||||
describe('FolderTree', () => {
|
||||
it('renders nothing when folders is empty', () => {
|
||||
const { container } = render(
|
||||
@@ -153,32 +177,16 @@ describe('FolderTree', () => {
|
||||
})
|
||||
|
||||
it('selects the vault root with the root path attached', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={defaultSelection}
|
||||
onSelect={onSelect}
|
||||
vaultRootPath={vaultRootPath}
|
||||
/>,
|
||||
)
|
||||
const { onSelect } = renderTree({ vaultRootPath })
|
||||
|
||||
fireEvent.click(screen.getByTestId('folder-row:'))
|
||||
clickFolderRow('')
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: '', rootPath: vaultRootPath })
|
||||
})
|
||||
|
||||
it('selects child folders with the vault root path attached when the tree has a root', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={defaultSelection}
|
||||
onSelect={onSelect}
|
||||
vaultRootPath={vaultRootPath}
|
||||
/>,
|
||||
)
|
||||
const { onSelect } = renderTree({ vaultRootPath })
|
||||
|
||||
fireEvent.click(screen.getByTestId('folder-row:areas'))
|
||||
clickFolderRow('areas')
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'areas', rootPath: vaultRootPath })
|
||||
})
|
||||
@@ -235,6 +243,120 @@ describe('FolderTree', () => {
|
||||
expect(screen.getByTestId('new-folder-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes the selected folder as the parent when creating a new folder', async () => {
|
||||
const onCreateFolder = vi.fn().mockResolvedValue(true)
|
||||
renderTree({
|
||||
selection: { kind: 'folder', path: 'projects', rootPath: vaultRootPath },
|
||||
onCreateFolder,
|
||||
vaultRootPath,
|
||||
})
|
||||
|
||||
await submitNewFolder('laputa')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onCreateFolder).toHaveBeenCalledWith('laputa', {
|
||||
path: 'projects',
|
||||
rootPath: vaultRootPath,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the target vault root when the vault root is selected', async () => {
|
||||
const onCreateFolder = vi.fn().mockResolvedValue(true)
|
||||
const otherVault = '/Users/luca/Team'
|
||||
const folders: FolderNode[] = [
|
||||
{
|
||||
name: 'Personal',
|
||||
path: '',
|
||||
rootPath: vaultRootPath,
|
||||
children: [{ name: 'areas', path: 'areas', rootPath: vaultRootPath, children: [] }],
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
path: '',
|
||||
rootPath: otherVault,
|
||||
children: [{ name: 'areas', path: 'areas', rootPath: otherVault, children: [] }],
|
||||
},
|
||||
]
|
||||
|
||||
renderTree({
|
||||
folders,
|
||||
selection: { kind: 'folder', path: '', rootPath: otherVault },
|
||||
onCreateFolder,
|
||||
vaultRootPath,
|
||||
})
|
||||
|
||||
await submitNewFolder('inbox')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onCreateFolder).toHaveBeenCalledWith('inbox', {
|
||||
path: '',
|
||||
rootPath: otherVault,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('omits parent context when nothing folder-like is selected', async () => {
|
||||
const onCreateFolder = vi.fn().mockResolvedValue(true)
|
||||
renderTree({ onCreateFolder, vaultRootPath })
|
||||
|
||||
await submitNewFolder('inbox')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onCreateFolder).toHaveBeenCalledWith('inbox', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
it('expands the selected parent after a successful create so the new child is visible', async () => {
|
||||
const onCreateFolder = vi.fn().mockResolvedValue(true)
|
||||
render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={{ kind: 'folder', path: 'projects' }}
|
||||
onSelect={vi.fn()}
|
||||
onCreateFolder={onCreateFolder}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Sanity: selecting a folder auto-expands its ancestors but not the folder
|
||||
// itself, so 'projects' starts collapsed.
|
||||
expect(screen.getByRole('button', { name: 'projects' })).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
fireEvent.click(screen.getByTestId('create-folder-btn'))
|
||||
const input = screen.getByTestId('new-folder-input')
|
||||
fireEvent.change(input, { target: { value: 'laputa' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onCreateFolder).toHaveBeenCalled()
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'projects' })).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves expansion alone when create resolves falsy (validation rejected, etc.)', async () => {
|
||||
const onCreateFolder = vi.fn().mockResolvedValue(false)
|
||||
render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={{ kind: 'folder', path: 'projects' }}
|
||||
onSelect={vi.fn()}
|
||||
onCreateFolder={onCreateFolder}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('create-folder-btn'))
|
||||
const input = screen.getByTestId('new-folder-input')
|
||||
fireEvent.change(input, { target: { value: 'laputa' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onCreateFolder).toHaveBeenCalled()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'projects' })).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('starts rename on folder double-click', () => {
|
||||
const onStartRenameFolder = vi.fn()
|
||||
render(
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Plus,
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { FolderNode, SidebarSelection } from '../types'
|
||||
import type { FolderCreationParent, FolderNode, SidebarSelection } from '../types'
|
||||
import { FolderContextMenu } from './folder-tree/FolderContextMenu'
|
||||
import { FolderNameInput } from './folder-tree/FolderNameInput'
|
||||
import { FolderTreeRow } from './folder-tree/FolderTreeRow'
|
||||
@@ -23,7 +23,7 @@ interface FolderTreeProps {
|
||||
folders: FolderNode[]
|
||||
selection: SidebarSelection
|
||||
onSelect: (selection: SidebarSelection) => void
|
||||
onCreateFolder?: (name: string) => Promise<boolean> | boolean
|
||||
onCreateFolder?: (name: string, parent?: FolderCreationParent) => Promise<boolean> | boolean
|
||||
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
|
||||
onDeleteFolder?: (folderPath: string) => void
|
||||
folderFileActions?: FolderFileActions
|
||||
@@ -94,6 +94,39 @@ function useDisplayedFolders(folders: FolderNode[], expanded: Record<string, boo
|
||||
}, [expanded, folders, locale, vaultRootPath])
|
||||
}
|
||||
|
||||
function creationParentForSelection(selection: SidebarSelection): FolderCreationParent | undefined {
|
||||
if (selection.kind !== 'folder') return undefined
|
||||
return { path: selection.path, rootPath: selection.rootPath }
|
||||
}
|
||||
|
||||
function useCreateFolderSubmit({
|
||||
closeCreateForm,
|
||||
expandFolder,
|
||||
onCreateFolder,
|
||||
selection,
|
||||
}: {
|
||||
closeCreateForm: () => void
|
||||
expandFolder: (key: string) => void
|
||||
onCreateFolder?: (name: string, parent?: FolderCreationParent) => Promise<boolean> | boolean
|
||||
selection: SidebarSelection
|
||||
}) {
|
||||
return useCallback(async (value: string) => {
|
||||
const nextName = value.trim()
|
||||
if (!nextName || !onCreateFolder) {
|
||||
closeCreateForm()
|
||||
return true
|
||||
}
|
||||
|
||||
const parent = creationParentForSelection(selection)
|
||||
const created = await onCreateFolder(nextName, parent)
|
||||
if (!created) return created
|
||||
|
||||
closeCreateForm()
|
||||
if (parent?.path) expandFolder(folderNodeKey(parent))
|
||||
return created
|
||||
}, [closeCreateForm, expandFolder, onCreateFolder, selection])
|
||||
}
|
||||
|
||||
export const FolderTree = memo(function FolderTree({
|
||||
folders,
|
||||
selection,
|
||||
@@ -113,6 +146,7 @@ export const FolderTree = memo(function FolderTree({
|
||||
const {
|
||||
closeCreateForm,
|
||||
expanded,
|
||||
expandFolder,
|
||||
handleToggleSection,
|
||||
isCreating,
|
||||
openCreateForm,
|
||||
@@ -139,17 +173,12 @@ export const FolderTree = memo(function FolderTree({
|
||||
onStartRenameFolder,
|
||||
})
|
||||
|
||||
const handleCreateFolderSubmit = useCallback(async (value: string) => {
|
||||
const nextName = value.trim()
|
||||
if (!nextName || !onCreateFolder) {
|
||||
closeCreateForm()
|
||||
return true
|
||||
}
|
||||
|
||||
const created = await onCreateFolder(nextName)
|
||||
if (created) closeCreateForm()
|
||||
return created
|
||||
}, [closeCreateForm, onCreateFolder])
|
||||
const handleCreateFolderSubmit = useCreateFolderSubmit({
|
||||
closeCreateForm,
|
||||
expandFolder,
|
||||
onCreateFolder,
|
||||
selection,
|
||||
})
|
||||
|
||||
const handleCreateFolderClick = useCallback(() => {
|
||||
closeContextMenu()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, memo } from 'react'
|
||||
import type {
|
||||
VaultEntry, FolderNode, SidebarSelection, ViewDefinition, ViewFile,
|
||||
FolderCreationParent, FolderNode, SidebarSelection, VaultEntry, ViewDefinition, ViewFile,
|
||||
} from '../types'
|
||||
import {
|
||||
KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent,
|
||||
@@ -56,7 +56,7 @@ interface SidebarProps {
|
||||
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>, rootPath?: string) => void
|
||||
onReorderViews?: (orderedFilenames: string[]) => void
|
||||
folders?: FolderNode[]
|
||||
onCreateFolder?: (name: string) => Promise<boolean> | boolean
|
||||
onCreateFolder?: (name: string, parent?: FolderCreationParent) => Promise<boolean> | boolean
|
||||
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
|
||||
onDeleteFolder?: (folderPath: string) => void
|
||||
folderFileActions?: FolderFileActions
|
||||
|
||||
@@ -35,8 +35,16 @@ function useExpandedFolders(selection: SidebarSelection, renamingFolderPath?: st
|
||||
})
|
||||
}, [])
|
||||
|
||||
const expandFolder = useCallback((key: string) => {
|
||||
setManualExpanded((current) => {
|
||||
if (Reflect.get(current, key) === true) return current
|
||||
return { ...current, [key]: true }
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
expanded,
|
||||
expandFolder,
|
||||
toggleFolder,
|
||||
}
|
||||
}
|
||||
@@ -85,7 +93,7 @@ export function useFolderTreeDisclosure({
|
||||
renamingFolderPath,
|
||||
selection,
|
||||
}: UseFolderTreeDisclosureInput) {
|
||||
const { expanded, toggleFolder } = useExpandedFolders(selection, renamingFolderPath)
|
||||
const { expanded, expandFolder, toggleFolder } = useExpandedFolders(selection, renamingFolderPath)
|
||||
const {
|
||||
closeCreateForm,
|
||||
handleToggleSection,
|
||||
@@ -97,6 +105,7 @@ export function useFolderTreeDisclosure({
|
||||
return {
|
||||
closeCreateForm,
|
||||
expanded,
|
||||
expandFolder,
|
||||
handleToggleSection,
|
||||
isCreating,
|
||||
openCreateForm,
|
||||
|
||||
102
src/components/status-bar/VaultMenu.applyMountedChange.test.ts
Normal file
102
src/components/status-bar/VaultMenu.applyMountedChange.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { applyMountedChange } from './vaultMenuMountedChange'
|
||||
import type { VaultOption } from './types'
|
||||
|
||||
function vault(path: string, mounted = true): VaultOption {
|
||||
return { label: path, path, alias: path.replace('/', ''), mounted, available: true }
|
||||
}
|
||||
|
||||
interface ChangeOverrides {
|
||||
defaultPath?: string
|
||||
vaultPath?: string
|
||||
path?: string
|
||||
mounted?: boolean
|
||||
includedVaults?: VaultOption[]
|
||||
}
|
||||
|
||||
function callApply(overrides: ChangeOverrides) {
|
||||
const onSetDefaultWorkspace = vi.fn()
|
||||
const onSwitchVault = vi.fn()
|
||||
const onUpdateWorkspaceIdentity = vi.fn()
|
||||
applyMountedChange({
|
||||
defaultPath: overrides.defaultPath ?? '/a',
|
||||
vaultPath: overrides.vaultPath ?? '/a',
|
||||
includedVaults: overrides.includedVaults ?? [vault('/a'), vault('/b')],
|
||||
mounted: overrides.mounted ?? false,
|
||||
path: overrides.path ?? '/a',
|
||||
callbacks: { onSetDefaultWorkspace, onSwitchVault, onUpdateWorkspaceIdentity },
|
||||
})
|
||||
return { onSetDefaultWorkspace, onSwitchVault, onUpdateWorkspaceIdentity }
|
||||
}
|
||||
|
||||
describe('applyMountedChange', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'both default and active vault',
|
||||
request: { defaultPath: '/a', vaultPath: '/a', path: '/a' },
|
||||
expectedDefault: '/b',
|
||||
expectedSwitch: '/b',
|
||||
expectedIdentity: ['/a', { mounted: false }],
|
||||
},
|
||||
{
|
||||
name: 'active vault only',
|
||||
request: { defaultPath: '/b', vaultPath: '/a', path: '/a' },
|
||||
expectedDefault: null,
|
||||
expectedSwitch: '/b',
|
||||
expectedIdentity: ['/a', { mounted: false }],
|
||||
},
|
||||
{
|
||||
name: 'default workspace only',
|
||||
request: { defaultPath: '/a', vaultPath: '/b', path: '/a' },
|
||||
expectedDefault: '/b',
|
||||
expectedSwitch: null,
|
||||
expectedIdentity: ['/a', { mounted: false }],
|
||||
},
|
||||
{
|
||||
name: 'neither default nor active vault',
|
||||
request: {
|
||||
defaultPath: '/a',
|
||||
vaultPath: '/a',
|
||||
path: '/b',
|
||||
includedVaults: [vault('/a'), vault('/b'), vault('/c')],
|
||||
},
|
||||
expectedDefault: null,
|
||||
expectedSwitch: null,
|
||||
expectedIdentity: ['/b', { mounted: false }],
|
||||
},
|
||||
])('handles unmounting $name', ({ request, expectedDefault, expectedSwitch, expectedIdentity }) => {
|
||||
const { onSetDefaultWorkspace, onSwitchVault, onUpdateWorkspaceIdentity } = callApply(request)
|
||||
|
||||
if (expectedDefault) expect(onSetDefaultWorkspace).toHaveBeenCalledWith(expectedDefault)
|
||||
else expect(onSetDefaultWorkspace).not.toHaveBeenCalled()
|
||||
if (expectedSwitch) expect(onSwitchVault).toHaveBeenCalledWith(expectedSwitch)
|
||||
else expect(onSwitchVault).not.toHaveBeenCalled()
|
||||
expect(onUpdateWorkspaceIdentity).toHaveBeenCalledWith(...expectedIdentity)
|
||||
})
|
||||
|
||||
it('bails out without changing anything when no alternative mounted vault exists', () => {
|
||||
const { onSetDefaultWorkspace, onSwitchVault, onUpdateWorkspaceIdentity } = callApply({
|
||||
defaultPath: '/a',
|
||||
vaultPath: '/a',
|
||||
path: '/a',
|
||||
includedVaults: [vault('/a')],
|
||||
})
|
||||
|
||||
expect(onSetDefaultWorkspace).not.toHaveBeenCalled()
|
||||
expect(onSwitchVault).not.toHaveBeenCalled()
|
||||
expect(onUpdateWorkspaceIdentity).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only marks the vault unmounted (no reroute) when remounting', () => {
|
||||
const { onSetDefaultWorkspace, onSwitchVault, onUpdateWorkspaceIdentity } = callApply({
|
||||
defaultPath: '/a',
|
||||
vaultPath: '/a',
|
||||
path: '/b',
|
||||
mounted: true,
|
||||
})
|
||||
|
||||
expect(onSetDefaultWorkspace).not.toHaveBeenCalled()
|
||||
expect(onSwitchVault).not.toHaveBeenCalled()
|
||||
expect(onUpdateWorkspaceIdentity).toHaveBeenCalledWith('/b', { mounted: true })
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ import type { VaultOption } from './types'
|
||||
import { useDismissibleLayer } from './useDismissibleLayer'
|
||||
import { workspaceAliasFromOption, workspaceIdentityFromVault } from '../../utils/workspaces'
|
||||
import { reorderVaultPath, vaultPathList } from '../../utils/vaultOrdering'
|
||||
import { applyMountedChange } from './vaultMenuMountedChange'
|
||||
|
||||
interface VaultMenuProps {
|
||||
vaults: VaultOption[]
|
||||
@@ -90,6 +91,7 @@ interface VaultMenuInteractionOptions {
|
||||
onSwitchVault: (path: string) => void
|
||||
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
setOpen: (open: boolean) => void
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface MountToggleRequest {
|
||||
@@ -104,10 +106,6 @@ interface VaultPathSelection extends VaultMenuInteractionOptions {
|
||||
path: string
|
||||
}
|
||||
|
||||
interface VaultMountChangeRequest extends VaultMenuInteractionOptions {
|
||||
mounted: boolean
|
||||
path: string
|
||||
}
|
||||
|
||||
function getVaultTriggerClassName(open: boolean, compact: boolean) {
|
||||
if (compact) {
|
||||
@@ -293,10 +291,6 @@ function useIncludedVaults(vaults: VaultOption[], defaultPath: string): VaultOpt
|
||||
return useMemo(() => vaults.filter((vault) => isIncludedVault(vault, defaultPath)), [defaultPath, vaults])
|
||||
}
|
||||
|
||||
function nextIncludedVaultPath(includedVaults: VaultOption[], currentPath: string): string | null {
|
||||
return includedVaults.find((vault) => vault.path !== currentPath)?.path ?? null
|
||||
}
|
||||
|
||||
function shouldDisableMountToggle({
|
||||
canSetDefaultWorkspace,
|
||||
defaultPath,
|
||||
@@ -321,22 +315,6 @@ function selectVaultPath({
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function applyMountedChange({
|
||||
defaultPath,
|
||||
includedVaults,
|
||||
mounted,
|
||||
onSetDefaultWorkspace,
|
||||
onUpdateWorkspaceIdentity,
|
||||
path,
|
||||
}: VaultMountChangeRequest): void {
|
||||
if (!mounted && path === defaultPath) {
|
||||
const nextDefaultPath = nextIncludedVaultPath(includedVaults, path)
|
||||
if (!nextDefaultPath) return
|
||||
onSetDefaultWorkspace?.(nextDefaultPath)
|
||||
}
|
||||
onUpdateWorkspaceIdentity?.(path, { mounted })
|
||||
}
|
||||
|
||||
function useVaultMenuInteractions({
|
||||
defaultPath,
|
||||
includedVaults,
|
||||
@@ -345,6 +323,7 @@ function useVaultMenuInteractions({
|
||||
onSwitchVault,
|
||||
onUpdateWorkspaceIdentity,
|
||||
setOpen,
|
||||
vaultPath,
|
||||
}: VaultMenuInteractionOptions) {
|
||||
const disableMountToggleForPath = useCallback((path: string) => (
|
||||
shouldDisableMountToggle({
|
||||
@@ -366,22 +345,24 @@ function useVaultMenuInteractions({
|
||||
onUpdateWorkspaceIdentity,
|
||||
path,
|
||||
setOpen,
|
||||
vaultPath,
|
||||
})
|
||||
}, [defaultPath, includedVaults, multiWorkspaceEnabled, onSetDefaultWorkspace, onSwitchVault, onUpdateWorkspaceIdentity, setOpen])
|
||||
}, [defaultPath, includedVaults, multiWorkspaceEnabled, onSetDefaultWorkspace, onSwitchVault, onUpdateWorkspaceIdentity, setOpen, vaultPath])
|
||||
|
||||
const handleMountedChange = useCallback((path: string, mounted: boolean) => {
|
||||
applyMountedChange({
|
||||
defaultPath,
|
||||
vaultPath,
|
||||
includedVaults,
|
||||
mounted,
|
||||
multiWorkspaceEnabled,
|
||||
onSetDefaultWorkspace,
|
||||
onSwitchVault,
|
||||
onUpdateWorkspaceIdentity,
|
||||
path,
|
||||
setOpen,
|
||||
callbacks: {
|
||||
onSetDefaultWorkspace,
|
||||
onSwitchVault,
|
||||
onUpdateWorkspaceIdentity,
|
||||
},
|
||||
})
|
||||
}, [defaultPath, includedVaults, multiWorkspaceEnabled, onSetDefaultWorkspace, onSwitchVault, onUpdateWorkspaceIdentity, setOpen])
|
||||
}, [defaultPath, includedVaults, onSetDefaultWorkspace, onSwitchVault, onUpdateWorkspaceIdentity, vaultPath])
|
||||
|
||||
return { disableMountToggleForPath, handleMountedChange, handleSelectVault }
|
||||
}
|
||||
@@ -747,6 +728,7 @@ export function VaultMenu(props: VaultMenuProps) {
|
||||
onSwitchVault,
|
||||
onUpdateWorkspaceIdentity,
|
||||
setOpen,
|
||||
vaultPath,
|
||||
})
|
||||
|
||||
useDismissibleLayer(open, menuRef, () => setOpen(false))
|
||||
|
||||
47
src/components/status-bar/vaultMenuMountedChange.ts
Normal file
47
src/components/status-bar/vaultMenuMountedChange.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { VaultOption } from './types'
|
||||
|
||||
export interface VaultMountChangeCallbacks {
|
||||
onSetDefaultWorkspace?: (path: string) => void
|
||||
onSwitchVault: (path: string) => void
|
||||
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
}
|
||||
|
||||
export interface VaultMountChangeRequest {
|
||||
defaultPath: string
|
||||
vaultPath: string
|
||||
includedVaults: VaultOption[]
|
||||
mounted: boolean
|
||||
path: string
|
||||
callbacks: VaultMountChangeCallbacks
|
||||
}
|
||||
|
||||
function nextIncludedVaultPath(includedVaults: VaultOption[], currentPath: string): string | null {
|
||||
return includedVaults.find((vault) => vault.path !== currentPath)?.path ?? null
|
||||
}
|
||||
|
||||
function isCurrentPathUnmount(request: VaultMountChangeRequest): boolean {
|
||||
if (request.mounted) return false
|
||||
if (request.path === request.defaultPath) return true
|
||||
return request.path === request.vaultPath
|
||||
}
|
||||
|
||||
function rerouteUnmountedPath(request: VaultMountChangeRequest): boolean {
|
||||
const nextPath = nextIncludedVaultPath(request.includedVaults, request.path)
|
||||
if (!nextPath) return false
|
||||
if (request.path === request.defaultPath) request.callbacks.onSetDefaultWorkspace?.(nextPath)
|
||||
if (request.path === request.vaultPath) request.callbacks.onSwitchVault(nextPath)
|
||||
return true
|
||||
}
|
||||
|
||||
export function applyMountedChange({
|
||||
defaultPath,
|
||||
vaultPath,
|
||||
includedVaults,
|
||||
mounted,
|
||||
path,
|
||||
callbacks,
|
||||
}: VaultMountChangeRequest): void {
|
||||
const request = { defaultPath, vaultPath, includedVaults, mounted, path, callbacks }
|
||||
if (isCurrentPathUnmount(request) && !rerouteUnmountedPath(request)) return
|
||||
callbacks.onUpdateWorkspaceIdentity?.(path, { mounted })
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const aiAgentsStatus = {
|
||||
opencode: { status: 'missing' as const, version: null },
|
||||
pi: { status: 'missing' as const, version: null },
|
||||
gemini: { status: 'missing' as const, version: null },
|
||||
kiro: { status: 'missing' as const, version: null },
|
||||
}
|
||||
|
||||
describe('useAiAgentPreferences', () => {
|
||||
@@ -86,6 +87,7 @@ describe('useAiAgentPreferences', () => {
|
||||
opencode: { status: 'missing', version: null },
|
||||
pi: { status: 'missing', version: null },
|
||||
gemini: { status: 'missing', version: null },
|
||||
kiro: { status: 'missing', version: null },
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -587,6 +587,40 @@ describe('useAutoSync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pullAndPush skips push for local-only vaults with no remote', async () => {
|
||||
const { result } = renderSync()
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
|
||||
mockInvokeFn.mockClear()
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_conflict_files') return Promise.resolve([])
|
||||
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
|
||||
if (cmd === 'git_remote_status') return Promise.resolve({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
|
||||
if (cmd === 'git_pull') {
|
||||
return Promise.resolve({
|
||||
status: 'no_remote',
|
||||
message: 'No remote configured',
|
||||
updatedFiles: [],
|
||||
conflictFiles: [],
|
||||
})
|
||||
}
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed successfully' })
|
||||
return Promise.resolve(upToDate())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.pullAndPush()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
|
||||
expect(mockInvokeFn).not.toHaveBeenCalledWith('git_push', { vaultPath: '/Users/luca/Laputa' })
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces pull conflicts and pull errors during recovery pushes', async () => {
|
||||
let mode: 'conflict' | 'error' = 'conflict'
|
||||
|
||||
|
||||
@@ -542,7 +542,17 @@ export function useAutoSync({
|
||||
})
|
||||
}
|
||||
|
||||
const pushOutcomes = await Promise.all(pullVaultPaths.map(async (path) => ({
|
||||
const pushVaultPaths = pullOutcomes
|
||||
.filter((outcome) => outcome.result.status !== 'no_remote')
|
||||
.map((outcome) => outcome.vaultPath)
|
||||
|
||||
if (pushVaultPaths.length === 0) {
|
||||
clearConflictState(setSyncStatus, setConflictFiles, setConflictVaultPath)
|
||||
void refreshRemoteStatus(pullVaultPaths)
|
||||
return
|
||||
}
|
||||
|
||||
const pushOutcomes = await Promise.all(pushVaultPaths.map(async (path) => ({
|
||||
result: await tauriCall<GitPushResult>('git_push', { vaultPath: path }),
|
||||
vaultPath: path,
|
||||
})))
|
||||
|
||||
@@ -148,6 +148,28 @@ describe('buildNoteContent', () => {
|
||||
})
|
||||
expect(content).toBe('---\ntype: Project\nstatus: Active\n---\n\n# \n\n## Objective\n\n')
|
||||
})
|
||||
|
||||
it('skips the empty H1 when the template already starts with one', () => {
|
||||
const content = buildNoteContent({
|
||||
title: null,
|
||||
type: 'Weekly',
|
||||
status: null,
|
||||
template: '# Woche 2026.21\n\nWochennotiz\n',
|
||||
initialEmptyHeading: true,
|
||||
})
|
||||
expect(content).toBe('---\ntype: Weekly\n---\n\n# Woche 2026.21\n\nWochennotiz\n')
|
||||
})
|
||||
|
||||
it('skips the empty H1 when the template starts with an H1 after leading whitespace', () => {
|
||||
const content = buildNoteContent({
|
||||
title: null,
|
||||
type: 'Weekly',
|
||||
status: null,
|
||||
template: '\n\n# Woche 2026.21\n',
|
||||
initialEmptyHeading: true,
|
||||
})
|
||||
expect(content).toBe('---\ntype: Weekly\n---\n\n\n\n# Woche 2026.21\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewNote', () => {
|
||||
|
||||
@@ -117,7 +117,8 @@ export interface TypeInstanceDefault {
|
||||
}
|
||||
|
||||
function buildNoteBody({ template, initialEmptyHeading }: Pick<NoteContentParams, 'template' | 'initialEmptyHeading'>): string {
|
||||
if (initialEmptyHeading) {
|
||||
const templateStartsWithH1 = template?.trimStart().startsWith('# ') ?? false
|
||||
if (initialEmptyHeading && !templateStartsWithH1) {
|
||||
return template ? `\n# \n\n${template}` : '\n# \n\n'
|
||||
}
|
||||
return template ? `\n${template}` : ''
|
||||
|
||||
@@ -59,6 +59,32 @@ describe('ai target provider contract', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts legacy agent ids saved in the default target field', () => {
|
||||
const target = resolveAiTarget({
|
||||
default_ai_agent: 'claude_code',
|
||||
default_ai_target: 'kiro',
|
||||
} as Settings)
|
||||
|
||||
expect(target).toMatchObject({
|
||||
kind: 'agent',
|
||||
agent: 'kiro',
|
||||
id: 'agent:kiro',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the legacy default agent when a saved agent target is stale', () => {
|
||||
const target = resolveAiTarget({
|
||||
default_ai_agent: 'kiro',
|
||||
default_ai_target: 'agent:claude_code',
|
||||
} as Settings)
|
||||
|
||||
expect(target).toMatchObject({
|
||||
kind: 'agent',
|
||||
agent: 'kiro',
|
||||
id: 'agent:kiro',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps provider defaults in one catalog with stable grouping metadata', () => {
|
||||
const entries = aiModelProviderCatalog()
|
||||
const kinds = entries.map((entry) => entry.kind)
|
||||
|
||||
@@ -124,19 +124,46 @@ export function agentTargets(): AiTarget[] {
|
||||
|
||||
export function resolveAiTarget(settings: Settings): AiTarget {
|
||||
const providers = normalizeAiModelProviders(settings.ai_model_providers)
|
||||
const targets = [...agentTargets(), ...configuredModelTargets(providers)]
|
||||
const storedTarget = settings.default_ai_target
|
||||
const target = targets.find((candidate) => candidate.id === storedTarget)
|
||||
if (target) return target
|
||||
const agents = agentTargets()
|
||||
const targets = [...agents, ...configuredModelTargets(providers)]
|
||||
const storedLegacyAgent = normalizeStoredAiAgent(settings.default_ai_agent)
|
||||
const legacyAgent = storedLegacyAgent ?? DEFAULT_AI_AGENT
|
||||
const target = resolveStoredAiTarget(settings.default_ai_target, targets)
|
||||
if (target) {
|
||||
if (shouldPreferLegacyAgent(target, storedLegacyAgent)) return agentTargetFor(agents, legacyAgent) ?? target
|
||||
return target
|
||||
}
|
||||
|
||||
const legacyAgent = normalizeStoredAiAgent(settings.default_ai_agent) ?? DEFAULT_AI_AGENT
|
||||
return agentTargets().find((candidate) => candidate.kind === 'agent' && candidate.agent === legacyAgent) ?? agentTargets()[0]
|
||||
return agentTargetFor(agents, legacyAgent) ?? agents[0]
|
||||
}
|
||||
|
||||
export function targetAgent(target: AiTarget): AiAgentId {
|
||||
return target.kind === 'agent' ? target.agent : DEFAULT_AI_AGENT
|
||||
}
|
||||
|
||||
function shouldPreferLegacyAgent(target: AiTarget, storedLegacyAgent: AiAgentId | null): boolean {
|
||||
if (target.kind !== 'agent') return false
|
||||
if (!storedLegacyAgent) return false
|
||||
if (target.agent !== DEFAULT_AI_AGENT) return false
|
||||
return target.agent !== storedLegacyAgent
|
||||
}
|
||||
|
||||
function agentTargetFor(agents: AiTarget[], agent: AiAgentId): AiTarget | undefined {
|
||||
return agents.find((candidate) => candidate.kind === 'agent' && candidate.agent === agent)
|
||||
}
|
||||
|
||||
function resolveStoredAiTarget(storedTarget: string | null | undefined, targets: AiTarget[]): AiTarget | null {
|
||||
const target = storedTarget?.trim()
|
||||
if (!target) return null
|
||||
|
||||
const exactTarget = targets.find((candidate) => candidate.id === target)
|
||||
if (exactTarget) return exactTarget
|
||||
|
||||
const legacyAgent = normalizeStoredAiAgent(target)
|
||||
if (!legacyAgent) return null
|
||||
return targets.find((candidate) => candidate.kind === 'agent' && candidate.agent === legacyAgent) ?? null
|
||||
}
|
||||
|
||||
export function normalizeAiModelProviders(providers: AiModelProvider[] | null | undefined): AiModelProvider[] {
|
||||
return (providers ?? []).map(normalizeAiModelProvider).filter((provider): provider is AiModelProvider => provider !== null)
|
||||
}
|
||||
|
||||
12
src/types.ts
12
src/types.ts
@@ -141,7 +141,7 @@ export interface GitPullResult {
|
||||
}
|
||||
|
||||
export interface GitPushResult {
|
||||
status: 'ok' | 'rejected' | 'auth_error' | 'network_error' | 'error'
|
||||
status: 'ok' | 'rejected' | 'auth_error' | 'network_error' | 'no_remote' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -276,3 +276,13 @@ export interface FolderNode {
|
||||
rootPath?: string
|
||||
children: FolderNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for a folder-create request: where the new folder should land.
|
||||
* `path` is vault-relative (`''` means vault root); `rootPath` identifies the
|
||||
* target vault when multiple workspaces are mounted.
|
||||
*/
|
||||
export interface FolderCreationParent {
|
||||
path: string
|
||||
rootPath?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user