feat: add multi-select notes with bulk archive and trash actions (#125)
* design: add multi-select notelist wireframes Three frames: selected notes with bulk action bar, Shift+click range select, and empty selection / hover states. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add multi-select notes with bulk archive and trash actions Cmd/Ctrl+Click toggles individual note selection, Shift+Click selects a range, Escape clears. A bulk action bar appears at the bottom with Archive and Trash buttons. Includes useMultiSelect hook, BulkActionBar component, Rust batch_archive_notes/batch_trash_notes commands, and 9 new tests covering the multi-select behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: trigger GitHub Actions run --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
422
design/notelist-multiselect.pen
Normal file
422
design/notelist-multiselect.pen
Normal file
@@ -0,0 +1,422 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_selected",
|
||||
"name": "Multi-select — 2 notes selected (bulk action bar visible)",
|
||||
"width": 340,
|
||||
"height": "fit_content(600)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"fill": "#FFFFFF",
|
||||
"layout": "vertical",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_hdr1",
|
||||
"name": "header",
|
||||
"width": "fill_container",
|
||||
"height": 52,
|
||||
"padding": [0, 16],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space_between",
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_h1t", "content": "Notes", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14, "fontWeight": "600" },
|
||||
{ "type": "frame", "id": "ms_h1icons", "gap": 12, "alignItems": "center", "children": [
|
||||
{ "type": "text", "id": "ms_h1s", "content": "\uD83D\uDD0D", "fontSize": 14 },
|
||||
{ "type": "text", "id": "ms_h1p", "content": "+", "fontSize": 16, "fill": "#9B9A97" }
|
||||
]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_item1",
|
||||
"name": "item-selected-1",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"fill": "#EBF5FF",
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_i1row",
|
||||
"width": "fill_container",
|
||||
"alignItems": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{ "type": "frame", "id": "ms_i1chk", "width": 16, "height": 16, "cornerRadius": 3, "fill": "#2383E2", "stroke": { "fill": "#2383E2", "thickness": 1 } },
|
||||
{ "type": "text", "id": "ms_i1t", "content": "Build Laputa App", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" }
|
||||
]
|
||||
},
|
||||
{ "type": "text", "id": "ms_i1s", "content": "Build a personal knowledge management app.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_item2",
|
||||
"name": "item-normal",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_i2row",
|
||||
"width": "fill_container",
|
||||
"alignItems": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{ "type": "frame", "id": "ms_i2chk", "width": 16, "height": 16, "cornerRadius": 3, "stroke": { "fill": "#D4D4D4", "thickness": 1 } },
|
||||
{ "type": "text", "id": "ms_i2t", "content": "Facebook Ads Strategy", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" }
|
||||
]
|
||||
},
|
||||
{ "type": "text", "id": "ms_i2s", "content": "Lookalike audiences convert 3x better.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_item3",
|
||||
"name": "item-selected-2",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"fill": "#EBF5FF",
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_i3row",
|
||||
"width": "fill_container",
|
||||
"alignItems": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{ "type": "frame", "id": "ms_i3chk", "width": 16, "height": 16, "cornerRadius": 3, "fill": "#2383E2", "stroke": { "fill": "#2383E2", "thickness": 1 } },
|
||||
{ "type": "text", "id": "ms_i3t", "content": "Matteo Cellini", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" }
|
||||
]
|
||||
},
|
||||
{ "type": "text", "id": "ms_i3s", "content": "Sponsorship manager.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_item4",
|
||||
"name": "item-normal-2",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_i4t", "content": "Kickoff Meeting", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
|
||||
{ "type": "text", "id": "ms_i4s", "content": "Project kickoff meeting notes.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{ "type": "frame", "id": "ms_spacer1", "width": "fill_container", "height": 120 },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_bulkbar",
|
||||
"name": "bulk-action-bar",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"padding": [0, 16],
|
||||
"fill": "#37352F",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space_between",
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_bcount", "content": "2 selected", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_bactions",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_barchive",
|
||||
"padding": [6, 12],
|
||||
"cornerRadius": 6,
|
||||
"fill": "#FFFFFF20",
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_bat1", "content": "Archive", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_btrash",
|
||||
"padding": [6, 12],
|
||||
"cornerRadius": 6,
|
||||
"fill": "#E03E3E30",
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_bat2", "content": "Move to Trash", "fill": "#E03E3E", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_bclose",
|
||||
"padding": [6, 8],
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_bx", "content": "\u2715", "fill": "#FFFFFF80", "fontSize": 14 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_range",
|
||||
"name": "Multi-select — range select Shift+click",
|
||||
"width": 340,
|
||||
"height": "fit_content(600)",
|
||||
"x": 400,
|
||||
"y": 0,
|
||||
"fill": "#FFFFFF",
|
||||
"layout": "vertical",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_hdr2",
|
||||
"name": "header",
|
||||
"width": "fill_container",
|
||||
"height": 52,
|
||||
"padding": [0, 16],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space_between",
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_h2t", "content": "Notes", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14, "fontWeight": "600" },
|
||||
{ "type": "frame", "id": "ms_h2icons", "gap": 12, "alignItems": "center", "children": [
|
||||
{ "type": "text", "id": "ms_h2s", "content": "\uD83D\uDD0D", "fontSize": 14 },
|
||||
{ "type": "text", "id": "ms_h2p", "content": "+", "fontSize": 16, "fill": "#9B9A97" }
|
||||
]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_r_annotation",
|
||||
"name": "annotation",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 16],
|
||||
"fill": "#FFF8E1",
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_ra_t", "content": "Shift+Click: range from first-clicked to here", "fill": "#D9730D", "fontFamily": "Inter", "fontSize": 11, "fontWeight": "500" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_r1",
|
||||
"name": "range-anchor",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"fill": "#EBF5FF",
|
||||
"stroke": { "align": "inside", "fill": "#2383E2", "thickness": { "left": 3, "bottom": 1 } },
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_r1t", "content": "Build Laputa App \u2190 anchor (first Cmd+click)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" },
|
||||
{ "type": "text", "id": "ms_r1s", "content": "Build a personal knowledge management app.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_r2",
|
||||
"name": "range-included",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"fill": "#EBF5FF",
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_r2t", "content": "Facebook Ads Strategy (auto-included in range)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
|
||||
{ "type": "text", "id": "ms_r2s", "content": "Lookalike audiences convert 3x better.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_r3",
|
||||
"name": "range-end",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"fill": "#EBF5FF",
|
||||
"stroke": { "align": "inside", "fill": "#2383E2", "thickness": { "left": 3, "bottom": 1 } },
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_r3t", "content": "Matteo Cellini \u2190 Shift+click target", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" },
|
||||
{ "type": "text", "id": "ms_r3s", "content": "Sponsorship manager.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_r4",
|
||||
"name": "range-outside",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_r4t", "content": "Kickoff Meeting (outside range)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
|
||||
{ "type": "text", "id": "ms_r4s", "content": "Project kickoff meeting notes.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{ "type": "frame", "id": "ms_rspacer", "width": "fill_container", "height": 80 },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_rbulkbar",
|
||||
"name": "bulk-action-bar",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"padding": [0, 16],
|
||||
"fill": "#37352F",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space_between",
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_rbcount", "content": "3 selected", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_rbactions",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "frame", "id": "ms_rbarchive", "padding": [6, 12], "cornerRadius": 6, "fill": "#FFFFFF20", "children": [
|
||||
{ "type": "text", "id": "ms_rba1", "content": "Archive", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }
|
||||
]},
|
||||
{ "type": "frame", "id": "ms_rbtrash", "padding": [6, 12], "cornerRadius": 6, "fill": "#E03E3E30", "children": [
|
||||
{ "type": "text", "id": "ms_rba2", "content": "Move to Trash", "fill": "#E03E3E", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }
|
||||
]},
|
||||
{ "type": "frame", "id": "ms_rbclose", "padding": [6, 8], "children": [
|
||||
{ "type": "text", "id": "ms_rbx", "content": "\u2715", "fill": "#FFFFFF80", "fontSize": 14 }
|
||||
]}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_empty",
|
||||
"name": "Multi-select — empty selection / hover state",
|
||||
"width": 340,
|
||||
"height": "fit_content(600)",
|
||||
"x": 800,
|
||||
"y": 0,
|
||||
"fill": "#FFFFFF",
|
||||
"layout": "vertical",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_hdr3",
|
||||
"name": "header",
|
||||
"width": "fill_container",
|
||||
"height": 52,
|
||||
"padding": [0, 16],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space_between",
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_h3t", "content": "Notes", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14, "fontWeight": "600" },
|
||||
{ "type": "frame", "id": "ms_h3icons", "gap": 12, "alignItems": "center", "children": [
|
||||
{ "type": "text", "id": "ms_h3s", "content": "\uD83D\uDD0D", "fontSize": 14 },
|
||||
{ "type": "text", "id": "ms_h3p", "content": "+", "fontSize": 16, "fill": "#9B9A97" }
|
||||
]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_e_annot",
|
||||
"name": "annotation",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 16],
|
||||
"fill": "#F5F5F5",
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_ea_t", "content": "No selection \u2014 Cmd+Click to select, Shift+Click for range", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 11 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_e1",
|
||||
"name": "item-active",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 13, 14, 16],
|
||||
"stroke": { "align": "inside", "fill": "#2383E2", "thickness": { "left": 3, "bottom": 1 } },
|
||||
"fill": "#EBF5FF",
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_e1t", "content": "Build Laputa App (current note \u2014 active)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" },
|
||||
{ "type": "text", "id": "ms_e1s", "content": "Build a personal knowledge management app.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_e2",
|
||||
"name": "item-hover",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"fill": "#F5F5F4",
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_e2t", "content": "Facebook Ads Strategy (hover state)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
|
||||
{ "type": "text", "id": "ms_e2s", "content": "Lookalike audiences convert 3x better.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_e3",
|
||||
"name": "item-default",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_e3t", "content": "Matteo Cellini", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
|
||||
{ "type": "text", "id": "ms_e3s", "content": "Sponsorship manager.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_e4",
|
||||
"name": "item-default-2",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_e4t", "content": "Kickoff Meeting", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
|
||||
{ "type": "text", "id": "ms_e4s", "content": "Project kickoff meeting notes.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ms_e_nobar",
|
||||
"name": "no-bar-annotation",
|
||||
"width": "fill_container",
|
||||
"padding": [12, 16],
|
||||
"fill": "#F5F5F5",
|
||||
"children": [
|
||||
{ "type": "text", "id": "ms_ena_t", "content": "\u2191 No bulk action bar when nothing is selected", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 11 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
@@ -119,6 +119,28 @@ fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
vault::migrate_is_a_to_type(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
frontmatter::update_frontmatter(path, "Archived", FrontmatterValue::Bool(true))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
frontmatter::update_frontmatter(path, "Trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(path, "Trashed at", FrontmatterValue::String(now.clone()))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_menu_state(app_handle: tauri::AppHandle, has_active_note: bool) -> Result<(), String> {
|
||||
menu::set_note_items_enabled(&app_handle, has_active_note);
|
||||
@@ -263,6 +285,8 @@ pub fn run() {
|
||||
save_image,
|
||||
purge_trash,
|
||||
migrate_is_a_to_type,
|
||||
batch_archive_notes,
|
||||
batch_trash_notes,
|
||||
get_settings,
|
||||
update_menu_state,
|
||||
save_settings,
|
||||
|
||||
21
src/App.tsx
21
src/App.tsx
@@ -41,6 +41,23 @@ declare global {
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
function useBulkActions(
|
||||
entryActions: { handleArchiveNote: (path: string) => Promise<void>; handleTrashNote: (path: string) => Promise<void> },
|
||||
setToastMessage: (msg: string | null) => void,
|
||||
) {
|
||||
const handleBulkArchive = useCallback(async (paths: string[]) => {
|
||||
for (const path of paths) await entryActions.handleArchiveNote(path)
|
||||
setToastMessage(`${paths.length} note${paths.length > 1 ? 's' : ''} archived`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
const handleBulkTrash = useCallback(async (paths: string[]) => {
|
||||
for (const path of paths) await entryActions.handleTrashNote(path)
|
||||
setToastMessage(`${paths.length} note${paths.length > 1 ? 's' : ''} moved to trash`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
return { handleBulkArchive, handleBulkTrash }
|
||||
}
|
||||
|
||||
function useLayoutPanels() {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(250)
|
||||
const [noteListWidth, setNoteListWidth] = useState(300)
|
||||
@@ -206,6 +223,8 @@ function App() {
|
||||
await notes.handleRenameNote(path, newTitle, vaultSwitcher.vaultPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
}, [notes, vaultSwitcher.vaultPath, vault, savePendingForPath])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
|
||||
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
|
||||
|
||||
const commands = useAppCommands({
|
||||
@@ -247,7 +266,7 @@ function App() {
|
||||
{noteListVisible && (
|
||||
<>
|
||||
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} getNoteStatus={vault.getNoteStatus} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} getNoteStatus={vault.getNoteStatus} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
</>
|
||||
|
||||
61
src/components/BulkActionBar.tsx
Normal file
61
src/components/BulkActionBar.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { memo } from 'react'
|
||||
import { Archive, Trash, X } from '@phosphor-icons/react'
|
||||
|
||||
interface BulkActionBarProps {
|
||||
count: number
|
||||
onArchive: () => void
|
||||
onTrash: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
function BulkActionBarInner({ count, onArchive, onTrash, onClear }: BulkActionBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
style={{
|
||||
height: 44,
|
||||
padding: '0 12px',
|
||||
background: 'var(--foreground)',
|
||||
color: 'var(--background)',
|
||||
}}
|
||||
data-testid="bulk-action-bar"
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{count} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
|
||||
style={{ padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={onArchive}
|
||||
title="Archive selected notes"
|
||||
data-testid="bulk-archive-btn"
|
||||
>
|
||||
<Archive size={14} />
|
||||
Archive
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none cursor-pointer"
|
||||
style={{ padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={onTrash}
|
||||
title="Move selected notes to trash"
|
||||
data-testid="bulk-trash-btn"
|
||||
>
|
||||
<Trash size={14} />
|
||||
Trash
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center border-none bg-transparent cursor-pointer"
|
||||
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}
|
||||
onClick={onClear}
|
||||
title="Clear selection"
|
||||
data-testid="bulk-clear-btn"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BulkActionBar = memo(BulkActionBarInner)
|
||||
@@ -52,9 +52,10 @@ const NOTE_STATUS_DOT: Record<string, { color: string; testId: string; title: st
|
||||
modified: { color: 'var(--accent-orange)', testId: 'modified-indicator', title: 'Modified (uncommitted)' },
|
||||
}
|
||||
|
||||
export function NoteItem({ entry, isSelected, noteStatus = 'clean', typeEntryMap, onClickNote }: {
|
||||
export function NoteItem({ entry, isSelected, isMultiSelected = false, noteStatus = 'clean', typeEntryMap, onClickNote }: {
|
||||
entry: VaultEntry
|
||||
isSelected: boolean
|
||||
isMultiSelected?: boolean
|
||||
noteStatus?: NoteStatus
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
@@ -68,14 +69,16 @@ export function NoteItem({ entry, isSelected, noteStatus = 'clean', typeEntryMap
|
||||
<div
|
||||
className={cn(
|
||||
"relative cursor-pointer border-b border-[var(--border)] transition-colors",
|
||||
isSelected && "border-l-[3px]",
|
||||
!isSelected && "hover:bg-muted"
|
||||
isSelected && !isMultiSelected && "border-l-[3px]",
|
||||
!isSelected && !isMultiSelected && "hover:bg-muted"
|
||||
)}
|
||||
style={{
|
||||
padding: isSelected ? '14px 16px 14px 13px' : '14px 16px',
|
||||
...(isSelected && { borderLeftColor: typeColor, backgroundColor: typeLightColor }),
|
||||
padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px',
|
||||
...(isMultiSelected && { backgroundColor: 'color-mix(in srgb, var(--accent-blue) 10%, transparent)' }),
|
||||
...(isSelected && !isMultiSelected && { borderLeftColor: typeColor, backgroundColor: typeLightColor }),
|
||||
}}
|
||||
onClick={(e: React.MouseEvent) => onClickNote(entry, e)}
|
||||
data-testid={isMultiSelected ? 'multi-selected-item' : undefined}
|
||||
>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
|
||||
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
|
||||
@@ -288,28 +288,27 @@ describe('NoteList click behavior', () => {
|
||||
expect(noopSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Click calls onSelectNote (opens in new tab)', () => {
|
||||
it('Cmd+Click toggles multi-select on a note', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('multi-selected-item')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Ctrl+Click calls onSelectNote (opens in new tab, Windows/Linux)', () => {
|
||||
it('Ctrl+Click toggles multi-select (Windows/Linux)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('multi-selected-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+Click on entity pinned card calls onSelectNote', () => {
|
||||
it('Cmd+Click on entity pinned card toggles multi-select', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Title appears in both header and pinned card — use getAllByText and click the pinned card instance
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
fireEvent.click(titles[titles.length - 1], { metaKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -988,3 +987,93 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- Multi-select tests ---
|
||||
|
||||
describe('NoteList — multi-select', () => {
|
||||
beforeEach(() => {
|
||||
noopSelect.mockClear()
|
||||
noopReplace.mockClear()
|
||||
})
|
||||
|
||||
it('Cmd+Click selects multiple notes', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
|
||||
const selected = screen.getAllByTestId('multi-selected-item')
|
||||
expect(selected).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('Cmd+Click toggles off an already-selected note', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(screen.getAllByTestId('multi-selected-item')).toHaveLength(1)
|
||||
// Toggle off
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('regular click clears multi-select and opens note', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// Multi-select two notes
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
|
||||
expect(screen.getAllByTestId('multi-selected-item')).toHaveLength(2)
|
||||
// Regular click clears selection and opens note
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'))
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[2])
|
||||
})
|
||||
|
||||
it('Shift+Click selects a range of notes', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// First Cmd+Click to set anchor
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
// Shift+Click to select range
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'), { shiftKey: true })
|
||||
// Should select all notes between Build Laputa App and Matteo Cellini (inclusive)
|
||||
const selected = screen.getAllByTestId('multi-selected-item')
|
||||
expect(selected.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('shows bulk action bar with correct count', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('bulk archive calls onBulkArchive and clears selection', () => {
|
||||
const onBulkArchive = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-archive-btn'))
|
||||
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('bulk trash calls onBulkTrash and clears selection', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-trash-btn'))
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clear button on bulk action bar clears selection', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('bulk-clear-btn'))
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('no bulk action bar when nothing is selected', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useCallback, memo } from 'react'
|
||||
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
|
||||
import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { NoteItem, getTypeIcon } from './NoteItem'
|
||||
import { SortDropdown } from './SortDropdown'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
import { useMultiSelect } from '../hooks/useMultiSelect'
|
||||
import {
|
||||
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
|
||||
getSortComparator,
|
||||
@@ -27,6 +29,8 @@ interface NoteListProps {
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onCreateNote: () => void
|
||||
onBulkArchive?: (paths: string[]) => void
|
||||
onBulkTrash?: (paths: string[]) => void
|
||||
}
|
||||
|
||||
function PinnedCard({ entry, typeEntryMap, onClickNote }: {
|
||||
@@ -190,12 +194,16 @@ function countExpiredTrash(entries: VaultEntry[]): number {
|
||||
|
||||
// --- Click routing ---
|
||||
|
||||
type MultiSelectActions = { toggle: (path: string) => void; selectRange: (path: string) => void; clear: () => void }
|
||||
|
||||
function routeNoteClick(
|
||||
entry: VaultEntry, e: React.MouseEvent,
|
||||
onSelectNote: (entry: VaultEntry) => void,
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void,
|
||||
multiSelect: MultiSelectActions,
|
||||
) {
|
||||
if (e.metaKey || e.ctrlKey) { onSelectNote(entry) } else { onReplaceActiveTab(entry) }
|
||||
if (e.shiftKey) { multiSelect.selectRange(entry.path) }
|
||||
else if (e.metaKey || e.ctrlKey) { multiSelect.toggle(entry.path) }
|
||||
else { multiSelect.clear(); onReplaceActiveTab(entry) }
|
||||
}
|
||||
|
||||
// --- Data hooks ---
|
||||
@@ -244,7 +252,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
|
||||
|
||||
const defaultGetNoteStatus = (): NoteStatus => 'clean'
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, onSelectNote, onReplaceActiveTab, onCreateNote }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
@@ -280,13 +288,50 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
const listDirection = listConfig.direction
|
||||
const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet })
|
||||
|
||||
const multiSelect = useMultiSelect(searched)
|
||||
|
||||
// Clear multi-select when sidebar selection changes
|
||||
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
|
||||
|
||||
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
||||
routeNoteClick(entry, e, onSelectNote, onReplaceActiveTab)
|
||||
}, [onSelectNote, onReplaceActiveTab])
|
||||
routeNoteClick(entry, e, onReplaceActiveTab, multiSelect)
|
||||
}, [onReplaceActiveTab, multiSelect])
|
||||
|
||||
// Keyboard: Escape to clear, Cmd+A to select all
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && multiSelect.isMultiSelecting) {
|
||||
e.preventDefault()
|
||||
multiSelect.clear()
|
||||
}
|
||||
if (e.key === 'a' && (e.metaKey || e.ctrlKey) && !isEntityView) {
|
||||
const active = document.activeElement
|
||||
const isInput = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || (active as HTMLElement)?.isContentEditable
|
||||
if (!isInput) {
|
||||
e.preventDefault()
|
||||
multiSelect.selectAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [multiSelect, isEntityView])
|
||||
|
||||
const handleBulkArchive = useCallback(() => {
|
||||
const paths = [...multiSelect.selectedPaths]
|
||||
multiSelect.clear()
|
||||
onBulkArchive?.(paths)
|
||||
}, [multiSelect, onBulkArchive])
|
||||
|
||||
const handleBulkTrash = useCallback(() => {
|
||||
const paths = [...multiSelect.selectedPaths]
|
||||
multiSelect.clear()
|
||||
onBulkTrash?.(paths)
|
||||
}, [multiSelect, onBulkTrash])
|
||||
|
||||
const renderItem = useCallback((entry: VaultEntry) => (
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus])
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
@@ -316,6 +361,10 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
<ListView typeDocument={typeDocument} isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{multiSelect.isMultiSelecting && (
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onClear={multiSelect.clear} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
67
src/hooks/useMultiSelect.ts
Normal file
67
src/hooks/useMultiSelect.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export interface MultiSelectState {
|
||||
selectedPaths: Set<string>
|
||||
isMultiSelecting: boolean
|
||||
toggle: (path: string) => void
|
||||
selectRange: (toPath: string) => void
|
||||
clear: () => void
|
||||
selectAll: () => void
|
||||
}
|
||||
|
||||
export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState {
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set())
|
||||
const lastClickedRef = useRef<string | null>(null)
|
||||
|
||||
const toggle = useCallback((path: string) => {
|
||||
setSelectedPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(path)) next.delete(path)
|
||||
else next.add(path)
|
||||
return next
|
||||
})
|
||||
lastClickedRef.current = path
|
||||
}, [])
|
||||
|
||||
const selectRange = useCallback((toPath: string) => {
|
||||
const fromPath = lastClickedRef.current
|
||||
if (!fromPath) {
|
||||
toggle(toPath)
|
||||
return
|
||||
}
|
||||
const paths = visibleEntries.map((e) => e.path)
|
||||
const fromIdx = paths.indexOf(fromPath)
|
||||
const toIdx = paths.indexOf(toPath)
|
||||
if (fromIdx === -1 || toIdx === -1) {
|
||||
toggle(toPath)
|
||||
return
|
||||
}
|
||||
const start = Math.min(fromIdx, toIdx)
|
||||
const end = Math.max(fromIdx, toIdx)
|
||||
setSelectedPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (let i = start; i <= end; i++) next.add(paths[i])
|
||||
return next
|
||||
})
|
||||
lastClickedRef.current = toPath
|
||||
}, [visibleEntries, toggle])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setSelectedPaths(new Set())
|
||||
lastClickedRef.current = null
|
||||
}, [])
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedPaths(new Set(visibleEntries.map((e) => e.path)))
|
||||
}, [visibleEntries])
|
||||
|
||||
return {
|
||||
selectedPaths,
|
||||
isMultiSelecting: selectedPaths.size > 0,
|
||||
toggle,
|
||||
selectRange,
|
||||
clear,
|
||||
selectAll,
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`,
|
||||
purge_trash: () => [],
|
||||
migrate_is_a_to_type: () => 0,
|
||||
batch_archive_notes: (args: { paths: string[] }) => args.paths.length,
|
||||
batch_trash_notes: (args: { paths: string[] }) => args.paths.length,
|
||||
github_device_flow_start: (): DeviceFlowStart => {
|
||||
mockDeviceFlowPollCount = 0
|
||||
return { device_code: 'mock_device_code_abc123', user_code: 'ABCD-1234', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 }
|
||||
|
||||
Reference in New Issue
Block a user