Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
476 changes: 475 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
Expand Down Expand Up @@ -58,8 +60,10 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"happy-dom": "20.11.1",
"tailwindcss": "^4.1.18",
"typescript": "~5.8.3",
"vite": "^7.0.4"
"vite": "^7.0.4",
"vitest": "4.1.10"
}
}
115 changes: 115 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ pub struct Settings {
pub ollama_model: Option<String>,
#[serde(rename = "foldersEnabled")]
pub folders_enabled: Option<bool>,
#[serde(rename = "sidebarSortOrder")]
pub sidebar_sort_order: Option<String>,
#[serde(rename = "ignoredPatterns")]
pub ignored_patterns: Option<Vec<String>>,
#[serde(rename = "customColorsLight")]
Expand Down Expand Up @@ -1795,6 +1797,39 @@ fn update_git_enabled(
Ok(())
}

#[tauri::command]
fn persist_sidebar_sort_order(
notes_folder: &str,
settings: &mut Settings,
order: String,
) -> Result<(), String> {
if order != "newest" && order != "oldest" {
return Err("Invalid sidebar sort order".to_string());
}

let previous_order = settings.sidebar_sort_order.replace(order);
if let Err(error) = save_settings(notes_folder, settings) {
settings.sidebar_sort_order = previous_order;
return Err(error.to_string());
}

Ok(())
}

#[tauri::command]
fn update_sidebar_sort_order(order: String, state: State<AppState>) -> Result<(), String> {
let folder = {
let app_config = state.app_config.read().expect("app_config read lock");
app_config
.notes_folder
.clone()
.ok_or("Notes folder not set")?
};

let mut settings = state.settings.write().expect("settings write lock");
persist_sidebar_sort_order(&folder, &mut settings, order)
}

#[tauri::command]
async fn write_file(path: String, contents: Vec<u8>) -> Result<(), String> {
fs::write(&path, contents)
Expand Down Expand Up @@ -3854,6 +3889,7 @@ pub fn run() {
get_settings,
update_settings,
update_git_enabled,
update_sidebar_sort_order,
preview_note_name,
write_file,
search_notes,
Expand Down Expand Up @@ -3994,3 +4030,82 @@ fn set_title_bar_theme(
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::{load_settings, persist_sidebar_sort_order, Settings};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

fn unique_test_path(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after Unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"scratch-sidebar-sort-{name}-{}-{nonce}",
std::process::id(),
))
}

#[test]
fn settings_preserve_sidebar_note_sort_order() {
let settings: Settings =
serde_json::from_str(r#"{"theme":{"mode":"system"},"sidebarSortOrder":"oldest"}"#)
.expect("settings should deserialize");

assert_eq!(settings.sidebar_sort_order.as_deref(), Some("oldest"));

let serialized = serde_json::to_value(settings).expect("settings should serialize");
assert_eq!(serialized["sidebarSortOrder"], "oldest");
}

#[test]
fn sidebar_sort_patch_persists_only_valid_orders() {
let notes_folder = unique_test_path("persist");
std::fs::create_dir_all(&notes_folder).expect("test folder should be created");
let notes_folder_string = notes_folder.to_string_lossy().into_owned();
let mut settings = Settings {
sidebar_sort_order: Some("newest".to_string()),
..Settings::default()
};

persist_sidebar_sort_order(&notes_folder_string, &mut settings, "oldest".to_string())
.expect("valid order should persist");
assert_eq!(settings.sidebar_sort_order.as_deref(), Some("oldest"));
assert_eq!(
load_settings(&notes_folder_string)
.sidebar_sort_order
.as_deref(),
Some("oldest"),
);

let error = persist_sidebar_sort_order(
&notes_folder_string,
&mut settings,
"unexpected".to_string(),
)
.expect_err("invalid order should fail");
assert_eq!(error, "Invalid sidebar sort order");
assert_eq!(settings.sidebar_sort_order.as_deref(), Some("oldest"));

std::fs::remove_dir_all(notes_folder).expect("test folder should be removed");
}

#[test]
fn sidebar_sort_patch_rolls_back_when_persistence_fails() {
let notes_folder = unique_test_path("rollback");
std::fs::write(&notes_folder, b"not a directory").expect("blocking file should be created");
let notes_folder_string = notes_folder.to_string_lossy().into_owned();
let mut settings = Settings {
sidebar_sort_order: Some("newest".to_string()),
..Settings::default()
};

persist_sidebar_sort_order(&notes_folder_string, &mut settings, "oldest".to_string())
.expect_err("write through a file path should fail");
assert_eq!(settings.sidebar_sort_order.as_deref(), Some("newest"));

std::fs::remove_file(notes_folder).expect("blocking file should be removed");
}
}
32 changes: 32 additions & 0 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
import { mod, shift, isMac, isWindows } from "../../lib/platform";
import * as notesService from "../../services/notes";
import { FolderNameDialog } from "../notes/FolderNameDialog";
import { NoteSortMenu } from "./SidebarControls";
import type { NoteSortOrder } from "../../types/note";

interface SidebarProps {
onOpenSettings?: () => void;
Expand All @@ -49,6 +51,8 @@ export function Sidebar({ onOpenSettings }: SidebarProps) {
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [folderDialogParent, setFolderDialogParent] = useState("");
const [foldersEnabled, setFoldersEnabled] = useState(true);
const [noteSortOrder, setNoteSortOrder] =
useState<NoteSortOrder>("newest");
const [dragLabel, setDragLabel] = useState<string | null>(null);
const [dragCount, setDragCount] = useState(1);
const [multiSelectedNoteIds, setMultiSelectedNoteIds] = useState<Set<string>>(new Set());
Expand Down Expand Up @@ -169,12 +173,35 @@ export function Sidebar({ onOpenSettings }: SidebarProps) {
useEffect(() => {
notesService.getSettings().then((s) => {
setFoldersEnabled(s.foldersEnabled === true);
setNoteSortOrder(
s.sidebarSortOrder === "oldest" ? "oldest" : "newest",
);
}).catch((error) => {
console.error("Failed to load settings:", error);
setFoldersEnabled(false);
});
}, []);

const handleNoteSortOrderChange = useCallback(
(nextSortOrder: NoteSortOrder) => {
if (nextSortOrder === noteSortOrder) return;

const previousSortOrder = noteSortOrder;
setNoteSortOrder(nextSortOrder);

void notesService
.updateSidebarSortOrder(nextSortOrder)
.catch((error) => {
console.error("Failed to save note sort order:", error);
setNoteSortOrder((current) =>
current === nextSortOrder ? previousSortOrder : current,
);
toast.error("Failed to save note sort order");
});
},
[noteSortOrder],
);

// Sync input with search query
useEffect(() => {
setInputValue(searchQuery);
Expand Down Expand Up @@ -321,6 +348,10 @@ export function Sidebar({ onOpenSettings }: SidebarProps) {
</div>
</div>
<div className="flex items-center gap-px">
<NoteSortMenu
sortOrder={noteSortOrder}
onChange={handleNoteSortOrderChange}
/>
<IconButton
onClick={toggleSearch}
title={`Search Notes (${mod}${isMac ? "" : "+"}${shift}${isMac ? "" : "+"}F)`}
Expand Down Expand Up @@ -413,6 +444,7 @@ export function Sidebar({ onOpenSettings }: SidebarProps) {

{/* Note list */}
<NoteList
sortOrder={noteSortOrder}
multiSelectedNoteIds={multiSelectedNoteIds}
setMultiSelectedNoteIds={setMultiSelectedNoteIds}
lastClickedNoteId={lastClickedNoteId}
Expand Down
82 changes: 82 additions & 0 deletions src/components/layout/SidebarControls.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "../ui";
import {
NoteSortMenu,
} from "./SidebarControls";

afterEach(() => {
document.body.replaceChildren();
});

describe("NoteSortMenu", () => {
it("offers newest and oldest ordering and reports the selected option", () => {
const onChange = vi.fn();
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);

act(() => {
root.render(
<TooltipProvider>
<NoteSortMenu sortOrder="newest" onChange={onChange} />
</TooltipProvider>,
);
});

const trigger = container.querySelector<HTMLButtonElement>(
'button[aria-label="Sort notes: Newest first"]',
);
expect(trigger).not.toBeNull();
expect(trigger?.tabIndex).toBe(0);

act(() => {
trigger?.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
button: 0,
pointerType: "mouse",
}),
);
});

const options = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
);
expect(options.map((option) => option.textContent?.trim())).toEqual([
"Newest first",
"Oldest first",
]);
expect(options[0]?.getAttribute("aria-checked")).toBe("true");

act(() => {
options[1]?.click();
});

expect(onChange).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith("oldest");

act(() => root.unmount());
});

it("does not prevent Radix focus restoration on the sort trigger", () => {
const onChange = vi.fn();
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);

act(() => {
root.render(
<TooltipProvider>
<NoteSortMenu sortOrder="newest" onChange={onChange} />
</TooltipProvider>,
);
});

const source = container.innerHTML;
expect(source).not.toContain("onCloseAutoFocus");

act(() => root.unmount());
Comment on lines +63 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test focus restoration through behavior.

Line 77 cannot detect onCloseAutoFocus. React does not serialize event props into innerHTML. This test will pass if a future change adds that handler. Close the menu and assert that focus returns to the sort trigger.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/layout/SidebarControls.test.tsx` around lines 63 - 80, Replace
the innerHTML assertion in the “does not prevent Radix focus restoration on the
sort trigger” test with a behavioral focus-restoration check: open and close the
sort menu, then assert that focus returns to the sort trigger. Use the existing
NoteSortMenu interaction path and preserve cleanup of the rendered root.

});
});
73 changes: 73 additions & 0 deletions src/components/layout/SidebarControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import type { NoteSortOrder } from "../../types/note";
import {
ArrowDownIcon,
ArrowUpIcon,
CheckIcon,
} from "../icons";
import { IconButton } from "../ui";

interface NoteSortMenuProps {
sortOrder: NoteSortOrder;
onChange: (sortOrder: NoteSortOrder) => void;
}

const radioItemClass =
"relative flex cursor-pointer items-center gap-2 px-3 py-1.5 pr-8 text-sm text-text outline-none hover:bg-bg-muted focus:bg-bg-muted data-[state=checked]:font-medium";

export function NoteSortMenu({
sortOrder,
onChange,
}: NoteSortMenuProps) {
const newestFirst = sortOrder === "newest";

return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<IconButton
title={`Sort notes: ${newestFirst ? "Newest" : "Oldest"} first`}
tabIndex={0}
className="active:scale-[0.96] motion-reduce:transform-none"
>
{newestFirst ? (
<ArrowDownIcon className="h-4.25 w-4.25 stroke-[1.5]" />
) : (
<ArrowUpIcon className="h-4.25 w-4.25 stroke-[1.5]" />
)}
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-50 min-w-44 rounded-md border border-border bg-bg py-1 shadow-lg"
sideOffset={5}
align="end"
>
<DropdownMenu.Label className="px-3 py-1 text-xs font-medium text-text-muted">
Sort notes
</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={sortOrder}
onValueChange={(value) => {
if (value === "newest" || value === "oldest") onChange(value);
}}
>
<DropdownMenu.RadioItem value="newest" className={radioItemClass}>
<ArrowDownIcon className="h-4 w-4 shrink-0 stroke-[1.6]" />
Newest first
<DropdownMenu.ItemIndicator className="absolute right-3 inline-flex items-center">
<CheckIcon className="h-3.5 w-3.5 stroke-[1.8]" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="oldest" className={radioItemClass}>
<ArrowUpIcon className="h-4 w-4 shrink-0 stroke-[1.6]" />
Oldest first
<DropdownMenu.ItemIndicator className="absolute right-3 inline-flex items-center">
<CheckIcon className="h-3.5 w-3.5 stroke-[1.8]" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
Loading