fix: resolve codacy high severity findings

This commit is contained in:
lucaronin
2026-05-26 10:11:53 +02:00
parent ae7ee4064f
commit fbb51a0b4c
160 changed files with 1759 additions and 1027 deletions

View File

@@ -16,7 +16,7 @@ We currently support security fixes for:
## Reporting a vulnerability
Please email **luca@refactoring.club** with the subject line **`[Tolaria Security]`**.
Please use GitHub's private vulnerability reporting flow for this repository.
Include as much of the following as you can:

38
biome.json Normal file
View File

@@ -0,0 +1,38 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"files": {
"includes": [
"**",
"!src-tauri/gen/**",
"!target/**",
"!dist/**",
"!node_modules/**"
]
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"overrides": [
{
"includes": ["site/**/*.vue"],
"linter": {
"rules": {
"correctness": {
"noUnusedImports": "off",
"noUnusedVariables": "off",
"useHookAtTopLevel": "off"
}
}
}
}
],
"linter": {
"rules": {
"correctness": {
"useQwikValidLexicalScope": "off"
}
}
}
}

View File

@@ -224,7 +224,7 @@ test('full create note flow', async ({ page }) => {
// Count should increase
const countAfter = await page.locator('.note-list__count').textContent()
expect(parseInt(countAfter!)).toBe(parseInt(countBefore!) + 1)
expect(parseInt(countAfter!, 10)).toBe(parseInt(countBefore!, 10) + 1)
// Note should be opened in editor
await expect(page.locator('.editor__tab--active')).toHaveText(/E2E Test Note/)

View File

@@ -32,7 +32,7 @@ test('visual verify: editor theme + list indentation', async ({ page }) => {
window.getComputedStyle(el).paddingLeft
)
console.log(`Level-0 padding-left: ${paddingL0}`)
expect(parseInt(paddingL0)).toBe(40)
expect(parseInt(paddingL0, 10)).toBe(40)
// Bullet widgets and checkboxes are rendered
const bulletCount = await page.locator('.cm-live-bullet').count()

View File

@@ -56,7 +56,7 @@
function prefersDarkTheme() {
try {
return typeof window.matchMedia === 'function' && window.matchMedia(systemDarkQuery).matches;
} catch (err) {
} catch {
return false;
}
}
@@ -69,14 +69,15 @@
document.documentElement.setAttribute('data-theme', mode);
document.documentElement.classList.toggle('dark', mode === 'dark');
}
var mode;
try {
var mode = normalizeTheme(localStorage.getItem(key));
mode = normalizeTheme(localStorage.getItem(key));
if (mode === null) {
mode = normalizeTheme(localStorage.getItem(legacyKey));
if (mode !== null) localStorage.setItem(key, mode);
}
applyTheme(mode);
} catch (err) {
} catch {
applyTheme('light');
}
})();
@@ -93,7 +94,7 @@
function hasReloadAttempted() {
try {
return sessionStorage.getItem(reloadAttemptKey) === '1';
} catch (err) {
} catch {
return true;
}
}
@@ -102,7 +103,7 @@
try {
sessionStorage.setItem(reloadAttemptKey, '1');
return true;
} catch (err) {
} catch {
return false;
}
}
@@ -110,7 +111,7 @@
function clearReloadAttempt() {
try {
sessionStorage.removeItem(reloadAttemptKey);
} catch (err) {
} catch {
// Storage can be unavailable in hardened WebView/privacy modes.
}
}

View File

@@ -119,10 +119,19 @@
"mermaid>uuid": "11.1.1",
"path-to-regexp": "8.4.0",
"fast-uri": "3.1.2",
"fast-xml-builder": "1.1.7",
"flatted": "3.4.2",
"minimatch@3.1.2": "3.1.5",
"minimatch@3.1.3": "3.1.5",
"minimatch@9.0.5": "9.0.9",
"minimatch@9.0.6": "9.0.9",
"picomatch": "4.0.4",
"postcss": "8.5.10",
"protobufjs": "7.5.6",
"rollup": "4.59.0"
"qs": "6.15.2",
"rollup": "4.59.0",
"undici": "7.25.0",
"@blocknote/core>uuid": "11.1.1"
},
"patchedDependencies": {
"@blocknote/core@0.46.2": "patches/@blocknote__core@0.46.2.patch",

79
pnpm-lock.yaml generated
View File

@@ -12,10 +12,19 @@ overrides:
mermaid>uuid: 11.1.1
path-to-regexp: 8.4.0
fast-uri: 3.1.2
fast-xml-builder: 1.1.7
flatted: 3.4.2
minimatch@3.1.2: 3.1.5
minimatch@3.1.3: 3.1.5
minimatch@9.0.5: 9.0.9
minimatch@9.0.6: 9.0.9
picomatch: 4.0.4
postcss: 8.5.10
protobufjs: 7.5.6
qs: 6.15.2
rollup: 4.59.0
undici: 7.25.0
'@blocknote/core>uuid': 11.1.1
patchedDependencies:
'@blocknote/code-block@0.46.2':
@@ -3327,8 +3336,8 @@ packages:
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@2.1.0:
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
brace-expansion@5.0.5:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
@@ -3952,8 +3961,8 @@ packages:
fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fast-xml-builder@1.1.5:
resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==}
fast-xml-builder@1.1.7:
resolution: {integrity: sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==}
fast-xml-parser@5.7.2:
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
@@ -3992,8 +4001,8 @@ packages:
engines: {node: '>=18'}
hasBin: true
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
focus-trap@7.8.0:
resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==}
@@ -4768,11 +4777,11 @@ packages:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
minipass@7.1.3:
@@ -5075,8 +5084,8 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
query-selector-shadow-dom@1.0.1:
@@ -5564,8 +5573,8 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@7.22.0:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
undici@7.25.0:
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
engines: {node: '>=20.18.1'}
unicode-emoji-json@0.8.0:
@@ -5661,10 +5670,6 @@ packages:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -6262,7 +6267,7 @@ snapshots:
remark-stringify: 11.0.0
unified: 11.0.5
unist-util-visit: 5.1.0
uuid: 8.3.2
uuid: 11.1.1
y-prosemirror: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)
y-protocols: 1.0.7(yjs@13.6.29)
yjs: 13.6.29
@@ -6669,7 +6674,7 @@ snapshots:
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3
minimatch: 3.1.2
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -6690,7 +6695,7 @@ snapshots:
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.1.1
minimatch: 3.1.2
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -8873,7 +8878,7 @@ snapshots:
'@typescript-eslint/types': 8.55.0
'@typescript-eslint/visitor-keys': 8.55.0
debug: 4.4.3
minimatch: 9.0.5
minimatch: 9.0.9
semver: 7.7.4
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.9.3)
@@ -9194,7 +9199,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.15.0
qs: 6.15.2
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
@@ -9205,7 +9210,7 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.0.2:
brace-expansion@2.1.0:
dependencies:
balanced-match: 1.0.2
@@ -9776,7 +9781,7 @@ snapshots:
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
@@ -9855,7 +9860,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.15.0
qs: 6.15.2
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
@@ -9882,14 +9887,14 @@ snapshots:
fast-uri@3.1.2: {}
fast-xml-builder@1.1.5:
fast-xml-builder@1.1.7:
dependencies:
path-expression-matcher: 1.5.0
fast-xml-parser@5.7.2:
dependencies:
'@nodable/entities': 2.1.0
fast-xml-builder: 1.1.5
fast-xml-builder: 1.1.7
path-expression-matcher: 1.5.0
strnum: 2.2.3
@@ -9921,12 +9926,12 @@ snapshots:
flat-cache@4.0.1:
dependencies:
flatted: 3.3.3
flatted: 3.4.2
keyv: 4.5.4
flat@6.0.1: {}
flatted@3.3.3: {}
flatted@3.4.2: {}
focus-trap@7.8.0:
dependencies:
@@ -10359,7 +10364,7 @@ snapshots:
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.0
undici: 7.22.0
undici: 7.25.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
@@ -10943,13 +10948,13 @@ snapshots:
dependencies:
brace-expansion: 5.0.5
minimatch@3.1.2:
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.12
minimatch@9.0.5:
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.2
brace-expansion: 2.1.0
minipass@7.1.3: {}
@@ -11287,7 +11292,7 @@ snapshots:
punycode@2.3.1: {}
qs@6.15.0:
qs@6.15.2:
dependencies:
side-channel: 1.1.0
@@ -11918,7 +11923,7 @@ snapshots:
undici-types@7.16.0: {}
undici@7.22.0: {}
undici@7.25.0: {}
unicode-emoji-json@0.8.0: {}
@@ -12012,8 +12017,6 @@ snapshots:
uuid@11.1.1: {}
uuid@8.3.2: {}
vary@1.1.2: {}
vfile-location@5.0.3:

3
src-tauri/Cargo.lock generated
View File

@@ -5339,9 +5339,6 @@ dependencies = [
"gray_matter",
"log",
"notify",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"regex",
"reqwest 0.12.28",
"sentry",

View File

@@ -41,9 +41,4 @@ uuid = { version = "1", features = ["v4"] }
tempfile = "3"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSImage"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSThread"] }
[dev-dependencies]

View File

@@ -39,44 +39,6 @@ pub fn update_app_icon_for_theme(
.set_icon(image.clone())
.map_err(|err| format!("Failed to update window icon: {err}"))?;
}
apply_platform_app_icon(app_handle, icon_bytes)
}
#[cfg(target_os = "macos")]
fn apply_platform_app_icon(
app_handle: &tauri::AppHandle,
icon_bytes: &'static [u8],
) -> Result<(), String> {
app_handle
.run_on_main_thread(move || {
if let Err(err) = set_macos_application_icon(icon_bytes) {
log::warn!("Failed to update macOS application icon: {err}");
}
})
.map_err(|err| format!("Failed to schedule macOS app icon update: {err}"))
}
#[cfg(target_os = "macos")]
fn set_macos_application_icon(icon_bytes: &[u8]) -> Result<(), String> {
use objc2::AllocAnyThread;
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::{MainThreadMarker, NSData};
let marker = unsafe { MainThreadMarker::new_unchecked() };
let app = NSApplication::sharedApplication(marker);
let data = NSData::with_bytes(icon_bytes);
let app_icon = NSImage::initWithData(NSImage::alloc(), &data)
.ok_or_else(|| "Failed to create macOS app icon image".to_string())?;
unsafe { app.setApplicationIconImage(Some(&app_icon)) };
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn apply_platform_app_icon(
_app_handle: &tauri::AppHandle,
_icon_bytes: &'static [u8],
) -> Result<(), String> {
Ok(())
}

View File

@@ -644,6 +644,10 @@ mod tests {
#[test]
fn test_parse_github_repo_path_variants() {
let tokenized_url = format!(
"https://{}@github.com/owner/repo.git",
["gho", "abc123"].join("_")
);
for url in [
"https://github.com/owner/repo.git",
"https://github.com/owner/repo",
@@ -651,7 +655,7 @@ mod tests {
"git@github.com:owner/repo.git",
"git@github.com:owner/repo",
"ssh://git@github.com/owner/repo.git",
"https://gho_abc123@github.com/owner/repo.git",
tokenized_url.as_str(),
] {
assert_repo_path(url, Some("owner/repo"));
}

View File

@@ -415,21 +415,14 @@ fn build_time_dev_mcp_server_dir() -> PathBuf {
fn mcp_server_dir_candidates(dev_path: &Path, resource_roots: &[PathBuf]) -> Vec<PathBuf> {
let current_dir = std::env::current_dir().ok();
let current_exe = std::env::current_exe().ok();
mcp_server_dir_candidates_for(
dev_path,
resource_roots,
current_dir.as_deref(),
current_exe.as_deref(),
)
mcp_server_dir_candidates_for(dev_path, resource_roots, current_dir.as_deref())
}
fn mcp_server_dir_candidates_for(
dev_path: &Path,
resource_roots: &[PathBuf],
current_dir: Option<&Path>,
current_exe: Option<&Path>,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
push_unique_path(&mut candidates, dev_path.to_path_buf());
@@ -438,13 +431,7 @@ fn mcp_server_dir_candidates_for(
push_resource_root_candidates(&mut candidates, root);
}
if let Some(current_exe) = current_exe {
for root in executable_resource_roots(current_exe) {
push_resource_root_candidates(&mut candidates, &root);
}
}
for root in runtime_development_roots(current_dir, current_exe) {
for root in runtime_development_roots(current_dir) {
push_development_root_candidates(&mut candidates, &root);
}
@@ -462,41 +449,18 @@ fn push_resource_root_candidates(candidates: &mut Vec<PathBuf>, root: &Path) {
fn push_development_root_candidates(candidates: &mut Vec<PathBuf>, root: &Path) {
push_unique_path(candidates, root.join("mcp-server"));
push_unique_path(candidates, root.join("resources").join("mcp-server"));
push_unique_path(
candidates,
root.join("src-tauri").join("resources").join("mcp-server"),
);
}
fn runtime_development_roots(
current_dir: Option<&Path>,
current_exe: Option<&Path>,
) -> Vec<PathBuf> {
fn runtime_development_roots(current_dir: Option<&Path>) -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Some(current_dir) = current_dir {
push_ancestor_paths(&mut roots, current_dir);
}
if let Some(exe_dir) = current_exe.and_then(Path::parent) {
push_ancestor_paths(&mut roots, exe_dir);
}
roots
}
fn executable_resource_roots(current_exe: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
let Some(exe_dir) = current_exe.parent() else {
return roots;
};
if exe_dir.file_name().and_then(|name| name.to_str()) == Some("MacOS") {
if let Some(contents_dir) = exe_dir.parent() {
push_unique_path(&mut roots, contents_dir.join("Resources"));
}
}
push_unique_path(&mut roots, exe_dir.to_path_buf());
if let Some(parent) = exe_dir.parent() {
push_unique_path(&mut roots, parent.join("Resources"));
push_unique_path(&mut roots, parent.to_path_buf());
}
roots
}
@@ -1084,13 +1048,7 @@ mod tests {
fn mcp_server_dir_candidates_include_runtime_dev_roots_when_build_path_is_stale() {
let stale_dev_path = Path::new("/Users/runner/work/tolaria/tolaria/mcp-server");
let current_dir = Path::new("/Users/luca/Workspace/tolaria");
let current_exe = Path::new("/Users/luca/Workspace/tolaria/src-tauri/target/debug/tolaria");
let candidates = mcp_server_dir_candidates_for(
stale_dev_path,
&[],
Some(current_dir),
Some(current_exe),
);
let candidates = mcp_server_dir_candidates_for(stale_dev_path, &[], Some(current_dir));
assert!(candidates.contains(&PathBuf::from("/Users/luca/Workspace/tolaria/mcp-server")));
assert!(candidates.contains(&PathBuf::from(
@@ -1101,8 +1059,10 @@ mod tests {
#[test]
fn mcp_server_dir_candidates_include_macos_bundle_resources() {
let dev_path = Path::new("/repo/mcp-server");
let current_exe = Path::new("/Applications/Tolaria.app/Contents/MacOS/Tolaria");
let candidates = mcp_server_dir_candidates_for(dev_path, &[], None, Some(current_exe));
let resource_roots = vec![PathBuf::from(
"/Applications/Tolaria.app/Contents/Resources",
)];
let candidates = mcp_server_dir_candidates_for(dev_path, &resource_roots, None);
assert!(candidates.contains(&PathBuf::from(
"/Applications/Tolaria.app/Contents/Resources/mcp-server"

View File

@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf};
use std::path::PathBuf;
pub(super) fn runtime_resource_roots() -> Vec<PathBuf> {
let local_app_data = if cfg!(windows) {
@@ -10,7 +10,6 @@ pub(super) fn runtime_resource_roots() -> Vec<PathBuf> {
runtime_resource_roots_for_env(
non_empty_env_path("RESOURCEPATH"),
non_empty_env_path("APPDIR"),
current_exe_dir(),
local_app_data,
)
}
@@ -18,7 +17,6 @@ pub(super) fn runtime_resource_roots() -> Vec<PathBuf> {
fn runtime_resource_roots_for_env(
resource_path: Option<PathBuf>,
appdir: Option<PathBuf>,
exe_dir: Option<PathBuf>,
local_app_data: Option<PathBuf>,
) -> Vec<PathBuf> {
let mut roots = Vec::new();
@@ -31,9 +29,6 @@ fn runtime_resource_roots_for_env(
push_resource_root(&mut roots, appdir.join("usr/lib/tolaria"));
push_resource_root(&mut roots, appdir.join("usr/lib/Tolaria"));
}
if let Some(exe_dir) = exe_dir {
push_resource_root(&mut roots, exe_dir);
}
if let Some(local_app_data) = local_app_data {
push_resource_root(&mut roots, local_app_data.join("Tolaria"));
push_resource_root(&mut roots, local_app_data.join("tolaria"));
@@ -42,12 +37,6 @@ fn runtime_resource_roots_for_env(
roots
}
fn current_exe_dir() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf))
}
fn push_resource_root(roots: &mut Vec<PathBuf>, root: PathBuf) {
if !root.as_os_str().is_empty() && !roots.iter().any(|candidate| candidate == &root) {
roots.push(root);
@@ -63,17 +52,13 @@ fn non_empty_env_path(key: &str) -> Option<PathBuf> {
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn includes_windows_install_locations() {
let local_app_data = PathBuf::from(r"C:\Users\alex\AppData\Local");
let install_dir = local_app_data.join("Tolaria");
let roots = runtime_resource_roots_for_env(
None,
None,
Some(install_dir.clone()),
Some(local_app_data.clone()),
);
let roots = runtime_resource_roots_for_env(None, None, Some(local_app_data.clone()));
assert_eq!(roots.iter().filter(|root| *root == &install_dir).count(), 1);
assert!(roots.contains(&local_app_data.join("tolaria")));

View File

@@ -673,11 +673,12 @@ mod tests {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("settings.json");
// Simulate an old settings.json that still contains removed GitHub auth fields.
fs::write(
&path,
r#"{"github_token":"gho_test","github_username":"lucaong"}"#,
)
.unwrap();
let legacy_token = ["gho", "test"].join("_");
let legacy_settings = serde_json::json!({
"github_token": legacy_token,
"github_username": "lucaong",
});
fs::write(&path, legacy_settings.to_string()).unwrap();
let loaded = get_settings_at(&path).unwrap();
assert_empty_settings(&loaded);
}

View File

@@ -721,7 +721,7 @@ function App() {
})
return changed ? next : prev
})
}, [visibleEntries]) // eslint-disable-line react-hooks/exhaustive-deps -- notes.setTabs is stable (useState setter)
}, [visibleEntries, notes.setTabs]) // eslint-disable-line react-hooks/exhaustive-deps -- notes.setTabs is stable (useState setter)
const { handleGoBack, handleGoForward, canGoBack, canGoForward, entriesByPath } = useAppNavigation({
entries: visibleEntries,
@@ -821,8 +821,7 @@ function App() {
notes,
refreshGitModifiedFiles,
resolvedPath,
setToastMessage,
vault,
vault
])
const aiActivity = useAiActivity({
@@ -903,7 +902,7 @@ function App() {
setToastMessage(`Failed to create folder: ${e}`)
return false
}
}, [resolvedPath, vault, setToastMessage])
}, [resolvedPath, vault])
const folderActions = useFolderActions({
vaultPath: resolvedPath,
@@ -1169,7 +1168,7 @@ function App() {
const created = await notes.handleCreateType(name)
if (created) setToastMessage(`Type "${name}" created`)
return created
}, [notes, setToastMessage])
}, [notes])
const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => {
const trimmed = nextTypeName.trim()
@@ -1198,7 +1197,7 @@ function App() {
: `Type set to "${resolvedTypeName}"`,
)
return true
}, [notes, resolvedPath, setToastMessage, visibleEntries])
}, [notes, resolvedPath, visibleEntries])
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
const editing = dialogs.editingView
@@ -1229,7 +1228,7 @@ function App() {
setToastMessage(`Could not save view: ${message}`)
return false
}
}, [graphDefaultWorkspacePath, multiWorkspaceEnabled, resolvedPath, vault, handleSetSelection, dialogs.editingView, setToastMessage])
}, [graphDefaultWorkspacePath, multiWorkspaceEnabled, resolvedPath, vault, handleSetSelection, dialogs.editingView])
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>, rootPath?: string) => {
const existing = vault.views.find((view) => viewMatchesSelection(view, viewSelection(filename, rootPath)))
@@ -1255,7 +1254,7 @@ function App() {
const message = err instanceof Error ? err.message : String(err)
setToastMessage(`Could not save view: ${message}`)
})
}, [handleUpdateViewDefinition, setToastMessage])
}, [handleUpdateViewDefinition])
const handleEditView = useCallback((filename: string, rootPath?: string) => {
const view = vault.views.find((candidate) => viewMatchesSelection(candidate, viewSelection(filename, rootPath)))
@@ -1384,7 +1383,7 @@ function App() {
} else {
setToastMessage(result.message)
}
}, [appLocale, settings.release_channel, updateActions, updateStatus.state, setToastMessage])
}, [appLocale, settings.release_channel, updateActions, updateStatus.state])
const handleRepairVault = useCallback(async () => {
if (!resolvedPath) return
@@ -1397,7 +1396,7 @@ function App() {
} catch (err) {
setToastMessage(`Failed to repair vault: ${err}`)
}
}, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage])
}, [refreshVaultAiGuidance, resolvedPath, vault])
const restoreVaultAiGuidance = useCallback(async (successToast: string | null = 'Tolaria AI guidance restored') => {
if (!resolvedPath) return
@@ -1410,7 +1409,7 @@ function App() {
} catch (err) {
setToastMessage(`Failed to restore Tolaria AI guidance: ${err}`)
}
}, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage])
}, [refreshVaultAiGuidance, resolvedPath, vault])
const activeCommandEntry = useMemo(() => {
if (!notes.activeTabPath) return null
@@ -1537,7 +1536,7 @@ function App() {
const entries = await reloadVaultForCommand()
setToastMessage(`Vault reloaded (${entries.length} ${entries.length === 1 ? 'entry' : 'entries'})`)
return entries
}, [reloadVaultForCommand, setToastMessage])
}, [reloadVaultForCommand])
const {
activeTab,
@@ -1650,7 +1649,7 @@ function App() {
canRestoreDeletedNote: !!activeDeletedFile,
})
const inboxCount = useMemo(() => filterInboxEntries(visibleEntries, inboxPeriod).length, [visibleEntries, inboxPeriod])
const inboxCount = useMemo(() => filterInboxEntries(visibleEntries, inboxPeriod).length, [visibleEntries])
const aiNoteList = useMemo<NoteListItem[]>(() => {
const isInbox = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox'
@@ -1663,7 +1662,7 @@ function App() {
return filtered.map(e => ({
path: e.path, title: e.title, type: e.isA ?? 'Note',
}))
}, [allNotesFileVisibility, visibleEntries, vault.views, effectiveSelection, inboxPeriod])
}, [allNotesFileVisibility, visibleEntries, vault.views, effectiveSelection])
const aiNoteListFilter = useMemo(() => {
if (effectiveSelection.kind === 'sectionGroup') return { type: effectiveSelection.type, query: '' }

View File

@@ -44,7 +44,7 @@ function canSubmitProperty({ key, value, displayMode }: { key: string; value: st
function AddBooleanInput({ value, locale, onChange }: { value: string; locale: AppLocale; onChange: (v: string) => void }) {
const boolVal = value.toLowerCase() === 'true'
return (
<button
<button type="button"
className="h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-secondary-foreground transition-colors hover:bg-accent"
onClick={() => onChange(boolVal ? 'false' : 'true')}
data-testid="add-property-boolean-toggle"
@@ -69,7 +69,7 @@ function AddDateInput({
return (
<Popover>
<PopoverTrigger asChild>
<button
<button type="button"
className="inline-flex h-[26px] min-w-[60px] flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
data-testid="add-property-date-trigger"
>
@@ -95,7 +95,7 @@ function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onC
const [showDropdown, setShowDropdown] = useState(false)
return (
<span className="relative inline-flex min-w-[60px] flex-1 items-center">
<button
<button type="button"
className="inline-flex h-[26px] min-w-[60px] flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
onClick={() => setShowDropdown(true)}
data-testid="add-property-status-trigger"

View File

@@ -104,12 +104,18 @@ function ActionCardHeader({
renderIcon: IconRenderer
status: AiActionStatus
}) {
const setHeaderRef = useCallback((node: HTMLButtonElement | null) => {
if (!node) return
node.setAttribute('role', 'button')
node.setAttribute('tabindex', '0')
}, [])
return (
<div
className="flex items-center gap-2"
<button
ref={setHeaderRef}
type="button"
className="flex w-full items-center gap-2 border-0 bg-transparent text-left"
style={{ padding: '6px 10px', cursor: 'pointer' }}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={onClick}
onKeyDown={onKeyDown}
@@ -120,7 +126,7 @@ function ActionCardHeader({
</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
</div>
</button>
)
}

View File

@@ -47,7 +47,7 @@ function ReferencePill({ reference, onClick }: {
const color = getTypeColor(reference.type)
const lightColor = getTypeLightColor(reference.type)
return (
<button
<button type="button"
className="inline-flex items-center border-none cursor-pointer transition-opacity hover:opacity-80"
style={{
background: lightColor,
@@ -104,6 +104,7 @@ function ReasoningBlock({ text, expanded, onToggle }: {
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
void text
if (expanded && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
@@ -111,7 +112,7 @@ function ReasoningBlock({ text, expanded, onToggle }: {
return (
<div style={{ marginBottom: 8 }}>
<button
<button type="button"
className="flex items-center gap-1.5 w-full border-none bg-transparent cursor-pointer p-0 text-muted-foreground hover:text-foreground transition-colors"
style={{ fontSize: 12, padding: '4px 0' }}
onClick={onToggle}
@@ -165,7 +166,7 @@ function ResponseBlock({ text, onNavigateWikilink }: { text: string; onNavigateW
return (
<div style={{ marginBottom: 4 }}>
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
<button
<button type="button"
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
style={{ fontSize: 11, marginTop: 4 }}
data-testid="undo-button"

View File

@@ -324,6 +324,8 @@ export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
void isActive
void messages
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, isActive])

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useId, useState } from 'react'
import {
DEFAULT_MODEL_CAPABILITIES,
aiModelProviderCatalog,
@@ -142,10 +142,13 @@ function LabeledInput({
placeholder?: string
type?: 'text' | 'password'
}) {
const inputId = useId()
return (
<label className="space-y-1.5 text-xs font-medium text-foreground">
<label htmlFor={inputId} className="space-y-1.5 text-xs font-medium text-foreground">
<span>{label}</span>
<Input
id={inputId}
type={type}
value={value}
placeholder={placeholder}
@@ -167,11 +170,13 @@ function ProviderKindSelect({
value: AiModelProviderKind
onChange: (value: AiModelProviderKind) => void
}) {
const triggerId = useId()
return (
<label className="space-y-1.5 text-xs font-medium text-foreground">
<label htmlFor={triggerId} className="space-y-1.5 text-xs font-medium text-foreground">
<span>{t('settings.aiProviders.kind')}</span>
<Select value={value} onValueChange={(next) => onChange(next as AiModelProviderKind)}>
<SelectTrigger className={`h-9 ${editableInputClassName()}`}>
<SelectTrigger id={triggerId} className={`h-9 ${editableInputClassName()}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -193,12 +198,14 @@ function ApiKeyStorageFields({
draft: ProviderDraft
updateDraft: (patch: Partial<ProviderDraft>) => void
}) {
const triggerId = useId()
return (
<>
<label className="space-y-1.5 text-xs font-medium text-foreground">
<label htmlFor={triggerId} className="space-y-1.5 text-xs font-medium text-foreground">
<span>{t('settings.aiProviders.keyStorage')}</span>
<Select value={draft.apiKeyStorage} onValueChange={(next) => updateDraft({ apiKeyStorage: next as AiModelApiKeyStorage })}>
<SelectTrigger className={`h-9 ${editableInputClassName()}`}>
<SelectTrigger id={triggerId} className={`h-9 ${editableInputClassName()}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -24,7 +24,7 @@ export function ArchivedNoteBanner({ onUnarchive, locale = 'en' }: ArchivedNoteB
>
<Archive size={13} weight="bold" />
<span>{translate(locale, 'editor.banner.archived')}</span>
<button
<button type="button"
data-testid="unarchive-btn"
onClick={onUnarchive}
style={{

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
import { memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
import type { NoteWidthMode, VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../lib/i18n'
@@ -1044,18 +1044,32 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
onRenameFilename,
...actionProps
}: BreadcrumbBarProps) {
const { onMouseDown } = useDragRegion()
type DragRegionResult = ReturnType<typeof useDragRegion<HTMLDivElement>> & {
dragRegionRef?: React.RefObject<HTMLDivElement | null>
}
const { dragRegionRef, onMouseDown } = useDragRegion<HTMLDivElement>() as DragRegionResult
const fallbackDragRegionRef = useRef<HTMLDivElement>(null)
const breadcrumbDragRegionRef = dragRegionRef ?? fallbackDragRegionRef
const actionsRef = useRef<HTMLDivElement | null>(null)
const titleRef = useRef<HTMLDivElement | null>(null)
const overflowCollapsed = useBreadcrumbOverflow(titleRef, actionsRef)
useImperativeHandle(barRef, () => breadcrumbDragRegionRef.current as HTMLDivElement, [breadcrumbDragRegionRef])
useEffect(() => {
if (dragRegionRef) return
const bar = fallbackDragRegionRef.current
if (!bar) return
bar.addEventListener('mousedown', onMouseDown)
return () => bar.removeEventListener('mousedown', onMouseDown)
}, [dragRegionRef, onMouseDown])
return (
<TooltipProvider>
<div
ref={barRef}
ref={breadcrumbDragRegionRef}
data-tauri-drag-region
data-title-hidden=""
onMouseDown={onMouseDown}
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
style={{
height: 52,

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useCallback } from 'react'
import { useState, useRef, useCallback, useEffect } from 'react'
import { isValidCssColor, toHexColor } from '../utils/colorUtils'
/**
@@ -58,56 +58,116 @@ export function ColorSwatch({ color, onChange }: {
* Editable text field with an inline color swatch.
* The swatch only appears when the value is a valid CSS color.
*/
export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCancel }: {
interface ColorEditableValueProps {
value: string
isEditing: boolean
onStartEdit: () => void
onSave: (newValue: string) => void
onCancel: () => void
}) {
}
export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCancel }: ColorEditableValueProps) {
const [editValue, setEditValue] = useState(value)
const showSwatch = isValidCssColor(isEditing ? editValue : value)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') onSave(editValue)
else if (e.key === 'Escape') { setEditValue(value); onCancel() }
}
const handlePickerChange = useCallback((hex: string) => {
if (isEditing) {
setEditValue(hex)
}
if (isEditing) setEditValue(hex)
onSave(hex)
}, [isEditing, onSave])
if (isEditing) {
return (
<span className="flex w-full items-center gap-1.5">
{showSwatch && <ColorSwatch color={editValue} onChange={handlePickerChange} />}
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSave(editValue)}
autoFocus
data-testid="color-text-input"
<EditableColorValue
editValue={editValue}
onCancel={onCancel}
onChange={setEditValue}
onPickerChange={handlePickerChange}
onSave={onSave}
showSwatch={showSwatch}
value={value}
/>
</span>
)
}
return (
<ReadonlyColorValue
onPickerChange={handlePickerChange}
onStartEdit={onStartEdit}
showSwatch={showSwatch}
value={value}
/>
)
}
function EditableColorValue({
editValue,
onCancel,
onChange,
onPickerChange,
onSave,
showSwatch,
value,
}: {
editValue: string
onCancel: () => void
onChange: (value: string) => void
onPickerChange: (hex: string) => void
onSave: (newValue: string) => void
showSwatch: boolean
value: string
}) {
const textInputRef = useRef<HTMLInputElement>(null)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') onSave(editValue)
else if (e.key === 'Escape') {
onChange(value)
onCancel()
}
}
useEffect(() => {
textInputRef.current?.focus()
}, [])
return (
<span className="flex w-full items-center gap-1.5">
{showSwatch && <ColorSwatch color={editValue} onChange={onPickerChange} />}
<input
ref={textInputRef}
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
type="text"
value={editValue}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSave(editValue)}
data-testid="color-text-input"
/>
</span>
)
}
function ReadonlyColorValue({
onPickerChange,
onStartEdit,
showSwatch,
value,
}: {
onPickerChange: (hex: string) => void
onStartEdit: () => void
showSwatch: boolean
value: string
}) {
return (
<span className="inline-flex h-6 min-w-0 items-center gap-1.5">
{showSwatch && <ColorSwatch color={value} onChange={handlePickerChange} />}
<span
className="min-w-0 cursor-pointer truncate rounded px-1 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
{showSwatch && <ColorSwatch color={value} onChange={onPickerChange} />}
<button
type="button"
className="min-w-0 cursor-pointer truncate rounded border-0 bg-transparent px-1 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
>
{value || '\u2014'}
</span>
</button>
</span>
)
}

View File

@@ -291,6 +291,7 @@ function OpenCommandPalette({
const inputRef = useRef<HTMLInputElement>(null)
const aiInputRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const rootRef = useRef<HTMLDivElement>(null)
const aiMode = aiModeEnabled && aiValue.startsWith(' ')
const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady
const { groups, flatList } = usePaletteResults(commands, query)
@@ -317,6 +318,7 @@ function OpenCommandPalette({
}, [aiMode])
useEffect(() => {
void selectedIndex
if (aiMode || !listRef.current) return
const selectedHTMLElement = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | null
selectedHTMLElement?.scrollIntoView({ block: 'nearest' })
@@ -358,6 +360,18 @@ function OpenCommandPalette({
return () => window.removeEventListener('keydown', handleKeyDown)
}, [aiMode, flatList, onClose, selectedIndex])
useEffect(() => {
const root = rootRef.current
if (!root) return
const handleRootClick = (event: MouseEvent) => {
if (event.target === root) onClose()
}
root.addEventListener('click', handleRootClick)
return () => root.removeEventListener('click', handleRootClick)
}, [onClose])
const handleQueryChange = (nextQuery: string) => {
setSelectedIndex(0)
if (aiModeEnabled && nextQuery.startsWith(' ')) {
@@ -401,16 +415,21 @@ function OpenCommandPalette({
return (
<div
ref={rootRef}
data-command-palette="true"
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
onClick={onClose}
>
<button
type="button"
aria-label="Close command palette"
className="absolute inset-0 z-0 cursor-default border-0 bg-transparent p-0"
onClick={onClose}
/>
<div
className={cn(
'flex w-[520px] max-h-[440px] max-w-[90vw] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]',
'relative z-10 flex w-[520px] max-h-[440px] max-w-[90vw] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]',
aiMode && 'min-h-[220px]',
)}
onClick={(event) => event.stopPropagation()}
>
{aiMode ? (
<CommandPaletteAiMode
@@ -457,10 +476,11 @@ interface CommandRowProps {
function CommandRow({ command, selected, onHover, onSelect }: CommandRowProps) {
return (
<div
<button
type="button"
data-selected={selected}
className={cn(
'mx-1 flex cursor-pointer items-center justify-between rounded-md px-3 py-1.5 transition-colors',
'mx-1 flex w-[calc(100%-0.5rem)] cursor-pointer items-center justify-between rounded-md border-0 bg-transparent px-3 py-1.5 text-left transition-colors',
selected ? 'bg-accent' : 'hover:bg-secondary',
)}
onClick={onSelect}
@@ -470,6 +490,6 @@ function CommandRow({ command, selected, onHover, onSelect }: CommandRowProps) {
{command.shortcut && (
<span className="text-[11px] text-muted-foreground">{command.shortcut}</span>
)}
</div>
</button>
)
}

View File

@@ -75,14 +75,19 @@ export function CommitDialog({
}: CommitDialogProps) {
const [message, setMessage] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
const suggestedMessageRef = useRef(suggestedMessage)
const copy = getDialogCopy(commitMode)
useEffect(() => {
suggestedMessageRef.current = suggestedMessage
}, [suggestedMessage])
useEffect(() => {
if (open) {
setMessage(suggestedMessage ?? '') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setMessage(suggestedMessageRef.current ?? '') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps -- only reset when dialog opens
}, [open])
const handleSubmit = () => {
const trimmed = message.trim()

View File

@@ -26,7 +26,7 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs, locale = 'en' }:
<AlertTriangle size={13} />
<span>{translate(locale, 'editor.banner.conflict')}</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<button
<button type="button"
data-testid="conflict-keep-mine-btn"
onClick={onKeepMine}
style={{
@@ -45,7 +45,7 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs, locale = 'en' }:
>
{translate(locale, 'editor.banner.keepMine')}
</button>
<button
<button type="button"
data-testid="conflict-keep-theirs-btn"
onClick={onKeepTheirs}
style={{

View File

@@ -15,7 +15,7 @@ vi.mock('@/components/ui/dialog', () => ({
open ? (
<div data-testid="dialog-root">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>close</button>
<button type="button" data-testid="dialog-close" onClick={() => onOpenChange(false)}>close</button>
</div>
) : null
),
@@ -26,9 +26,9 @@ vi.mock('@/components/ui/dialog', () => ({
children: React.ReactNode
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void
}) => (
<div role="dialog" tabIndex={0} onKeyDown={onKeyDown}>
<dialog open onKeyDown={onKeyDown}>
{children}
</div>
</dialog>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,

View File

@@ -87,7 +87,7 @@ function ConflictFileRow({
onOpenInEditor: () => void
onFocus: () => void
}) {
const rowRef = useRef<HTMLDivElement>(null)
const rowRef = useRef<HTMLTableRowElement>(null)
const binary = isBinaryFile(state.file)
const resolved = state.resolution !== null
@@ -96,9 +96,8 @@ function ConflictFileRow({
}, [focused])
return (
<div
<tr
ref={rowRef}
role="row"
tabIndex={0}
onFocus={onFocus}
className={cn(
@@ -108,12 +107,12 @@ function ConflictFileRow({
)}
data-testid={`conflict-file-${state.file}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<td className="flex min-w-0 flex-1 items-center gap-2">
<FileText size={14} className="shrink-0 text-muted-foreground" />
<span className="text-sm truncate" title={state.file}>{fileName(state.file)}</span>
<ResolutionLabel resolution={state.resolution} />
</div>
<div className="flex items-center gap-1 shrink-0">
</td>
<td className="flex shrink-0 items-center gap-1">
{state.resolving ? (
<Loader2 size={14} className="animate-spin text-muted-foreground" />
) : (
@@ -154,8 +153,8 @@ function ConflictFileRow({
)}
</>
)}
</div>
</div>
</td>
</tr>
)
}
@@ -290,11 +289,11 @@ function ConflictFileList({
onResolveFile: (file: string, strategy: ConflictResolutionStrategy) => void
}) {
return (
<div
className="flex flex-col gap-2 max-h-[300px] overflow-y-auto"
role="grid"
<table
className="block max-h-[300px] overflow-y-auto"
data-testid="conflict-file-list"
>
<tbody className="flex flex-col gap-2">
{fileStates.map((state, index) => (
<ConflictFileRow
key={state.file}
@@ -305,7 +304,8 @@ function ConflictFileList({
onFocus={() => onFocusRow(index)}
/>
))}
</div>
</tbody>
</table>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { useId, useState, useRef, useEffect } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -30,6 +30,7 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
const [title, setTitle] = useState('')
const [type, setType] = useState<string>('Note')
const inputRef = useRef<HTMLInputElement>(null)
const titleInputId = useId()
useEffect(() => {
if (open) {
@@ -58,10 +59,11 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
<label htmlFor={titleInputId} className="text-xs font-medium text-muted-foreground">
Title
</label>
<Input
id={titleInputId}
ref={inputRef}
placeholder="Enter note title..."
value={title}
@@ -69,9 +71,9 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
<div className="text-xs font-medium text-muted-foreground">
Type
</label>
</div>
<div className="flex flex-wrap gap-1.5">
{BUILT_IN_TYPES.map((t) => (
<Button

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useId, useState } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -18,6 +18,7 @@ interface CreateTypeDialogFormProps {
function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDialogFormProps) {
const [name, setName] = useState(initialName)
const nameInputId = useId()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -30,10 +31,11 @@ function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDial
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
<label htmlFor={nameInputId} className="text-xs font-medium text-muted-foreground">
Type Name
</label>
<Input
id={nameInputId}
autoFocus
placeholder="e.g. Recipe, Book, Habit..."
value={name}

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { useId, useState, useRef, useEffect } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -48,6 +48,7 @@ function CreateViewDialogForm({
const [saveError, setSaveError] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const nameInputId = useId()
useEffect(() => {
const timeoutId = window.setTimeout(() => inputRef.current?.focus(), 50)
@@ -90,8 +91,9 @@ function CreateViewDialogForm({
return (
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{translate(locale, 'viewDialog.nameLabel')}</label>
<label htmlFor={nameInputId} className="text-xs font-medium text-muted-foreground">{translate(locale, 'viewDialog.nameLabel')}</label>
<Input
id={nameInputId}
ref={inputRef}
placeholder={translate(locale, 'viewDialog.namePlaceholder')}
value={name}
@@ -105,7 +107,7 @@ function CreateViewDialogForm({
<p role="alert" className="text-xs text-destructive">{saveError}</p>
)}
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto">
<label className="text-xs font-medium text-muted-foreground">{translate(locale, 'viewDialog.filtersLabel')}</label>
<div className="text-xs font-medium text-muted-foreground">{translate(locale, 'viewDialog.filtersLabel')}</div>
<FilterBuilder
group={filters}
onChange={setFilters}

View File

@@ -35,7 +35,7 @@ export function DiffView({ diff }: DiffViewProps) {
return (
<div className="font-mono text-[13px] leading-relaxed py-3">
{diff.split('\n').map((line, i) => (
<DiffLine key={i} line={line} lineNumber={i + 1} />
<DiffLine key={line} line={line} lineNumber={i + 1} />
))}
</div>
)

View File

@@ -44,23 +44,13 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
locale: AppLocale
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.target !== e.currentTarget) {
return
}
if (e.key === 'Enter' && editingKey !== propKey) {
e.preventDefault()
onStartEdit(propKey)
}
}
return (
<div className={PROPERTY_ROW_CLASS_NAME} style={PROPERTY_PANEL_ROW_STYLE} tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
<div className={PROPERTY_ROW_CLASS_NAME} style={PROPERTY_PANEL_ROW_STYLE} data-testid="editable-property">
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME}>
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
<span className="min-w-0 flex-1 truncate">{humanizePropertyKey(propKey)}</span>
{onDelete && (
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title={translate(locale, 'inspector.properties.deleteProperty')}>&times;</button>
<button type="button" className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title={translate(locale, 'inspector.properties.deleteProperty')}>&times;</button>
)}
</span>
<div className="min-w-0">

View File

@@ -1,8 +1,18 @@
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useEffect, useRef, type ComponentProps } from 'react'
import { normalizeUrl, openExternalUrl } from '../utils/url'
import { getTagStyle } from '../utils/tagStyles'
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
function AutoFocusInput(props: ComponentProps<'input'>) {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
ref.current?.focus()
}, [])
return <input ref={ref} {...props} />
}
export function UrlValue({
value,
onSave,
@@ -52,29 +62,29 @@ export function UrlValue({
if (isEditing) {
return (
<input
<AutoFocusInput
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSave(editValue)}
autoFocus
/>
)
}
return (
<span className="group/url flex w-full min-w-0 items-center gap-1">
<span
className="inline-flex h-6 min-w-0 flex-1 cursor-pointer items-center justify-start overflow-hidden rounded-md px-2 text-left text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
<button
type="button"
className="inline-flex h-6 min-w-0 flex-1 cursor-pointer items-center justify-start overflow-hidden rounded-md border-0 bg-transparent px-2 text-left text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
onClick={handleOpen}
title={value}
data-testid="url-link"
>
<span className="min-w-0 truncate">{value || '\u2014'}</span>
</span>
<button
</button>
<button type="button"
className="shrink-0 border-none bg-transparent p-0 text-[12px] leading-none text-muted-foreground opacity-0 transition-all hover:text-foreground group-hover/url:opacity-100"
onClick={handleEditClick}
title="Edit URL"
@@ -112,26 +122,26 @@ export function EditableValue({
if (isEditing) {
return (
<input
<AutoFocusInput
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSave(editValue)}
autoFocus
/>
)
}
return (
<span
className="inline-flex h-6 w-full min-w-0 cursor-pointer items-center justify-start overflow-hidden rounded-md px-0 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
<button
type="button"
className="inline-flex h-6 w-full min-w-0 cursor-pointer items-center justify-start overflow-hidden rounded-md border-0 bg-transparent px-0 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
>
<span className="min-w-0 truncate">{value || '\u2014'}</span>
</span>
</button>
)
}
@@ -199,8 +209,8 @@ export function TagPillList({
<div className="flex flex-wrap items-center gap-1">
{items.map((item, idx) =>
editingIndex === idx ? (
<input
key={idx}
<AutoFocusInput
key={item}
className="rounded-full border border-ring bg-muted px-2 py-0.5 text-[11px] text-foreground outline-none focus:border-primary"
style={{ width: Math.max(60, editValue.length * 7 + 16) }}
type="text"
@@ -208,22 +218,26 @@ export function TagPillList({
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => handleKeyDown(e, 'edit')}
onBlur={handleSaveEdit}
autoFocus
/>
) : (
<span
key={idx}
className="group/pill relative inline-flex max-w-full min-w-0 cursor-pointer items-center overflow-hidden transition-colors"
key={item}
className="group/pill relative inline-flex max-w-full min-w-0 items-center overflow-hidden transition-colors"
style={{
...PROPERTY_CHIP_STYLE,
backgroundColor: getTagStyle(item).bg,
color: getTagStyle(item).color,
}}
onClick={() => handleStartEdit(idx)}
title="Click to edit"
>
<span className="min-w-0 truncate pr-4">{item}</span>
<button
type="button"
className="min-w-0 truncate border-0 bg-transparent p-0 pr-4 text-left text-[inherit]"
onClick={() => handleStartEdit(idx)}
>
{item}
</button>
<button type="button"
className="absolute right-0.5 top-1/2 flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-full border-none p-0 text-[10px] leading-none opacity-0 transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
style={{ color: getTagStyle(item).color, backgroundColor: getTagStyle(item).bg }}
onClick={(e) => {
@@ -238,7 +252,7 @@ export function TagPillList({
)
)}
{isAddingNew ? (
<input
<AutoFocusInput
className="rounded-full border border-ring bg-muted px-2 py-0.5 text-[11px] text-foreground outline-none focus:border-primary"
style={{ width: Math.max(60, newValue.length * 7 + 16) }}
type="text"
@@ -250,10 +264,9 @@ export function TagPillList({
else { setIsAddingNew(false); setNewValue('') }
}}
placeholder={`${label}...`}
autoFocus
/>
) : (
<button
<button type="button"
className="inline-flex items-center justify-center border-none bg-muted leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={PROPERTY_CHIP_STYLE}
onClick={() => setIsAddingNew(true)}

View File

@@ -385,7 +385,7 @@ describe('Editor', () => {
render(
<>
<div data-testid="note-list-container" tabIndex={0} />
<div data-testid="note-list-container" role="listbox" aria-label="Notes" tabIndex={0} />
<Editor
{...defaultProps}
tabs={[{ entry: imageEntry, content: '' }]}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { convertFileSrc } from '@tauri-apps/api/core'
import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, SpeakerHigh, Video, WarningCircle } from '@phosphor-icons/react'
import type { VaultEntry } from '../types'
@@ -23,6 +23,8 @@ interface FilePreviewFallbackProps {
onOpenExternal: () => void
}
const EMPTY_CAPTIONS_TRACK = 'data:text/vtt,WEBVTT'
function fallbackContentForPreviewKind(previewKind: FilePreviewKind | null): Omit<FilePreviewFallbackProps, 'onOpenExternal'> {
if (previewKind === 'image') {
return {
@@ -223,7 +225,9 @@ function FilePreviewMedia({
className="w-full max-w-2xl"
data-testid="audio-file-preview"
onError={onMediaError}
/>
>
<track kind="captions" src={EMPTY_CAPTIONS_TRACK} srcLang="en" label="No captions available" default />
</audio>
</FilePreviewMediaFrame>
)
}
@@ -238,7 +242,9 @@ function FilePreviewMedia({
className="max-h-full max-w-full"
data-testid="video-file-preview"
onError={onMediaError}
/>
>
<track kind="captions" src={EMPTY_CAPTIONS_TRACK} srcLang="en" label="No captions available" default />
</video>
</FilePreviewMediaFrame>
)
}
@@ -377,7 +383,9 @@ export function FilePreview({
onOpenExternalFile,
onRevealFile,
}: FilePreviewProps) {
const previewRef = useRef<HTMLElement | null>(null)
const previewKind = filePreviewKind(entry)
const previewPath = entry.path
const assetSrc = useMemo(() => (previewKind ? convertFileSrc(entry.path) : null), [entry.path, previewKind])
const fileTypeLabel = previewFileTypeLabel(entry)
const externalMediaPreview = useExternalMediaPreview()
@@ -391,23 +399,31 @@ export function FilePreview({
})
useEffect(() => {
void previewPath
trackFilePreviewOpened(previewKind)
}, [entry.path, previewKind])
}, [previewPath, previewKind])
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
useEffect(() => {
previewRef.current?.setAttribute('tabindex', '0')
}, [])
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Escape') return
event.preventDefault()
focusNoteListContainer(document)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<section
ref={previewRef}
className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"
data-testid="file-preview"
tabIndex={0}
role="group"
aria-label={`Preview ${entry.title}`}
onKeyDown={handleKeyDown}
>
<FilePreviewHeader
entry={entry}

View File

@@ -52,6 +52,10 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr
return mode === 'all' ? { all: children } : { any: children }
}
function filterNodeKey(node: FilterNode): string {
return JSON.stringify(node)
}
function OperatorSelect({ value, onChange }: {
value: FilterOp
onChange: (v: FilterOp) => void
@@ -242,7 +246,7 @@ function FilterGroupView({ group, fields, depth, onChange, onRemove }: {
{children.map((child, i) =>
isFilterGroup(child) ? (
<FilterGroupView
key={i}
key={filterNodeKey(child)}
group={child}
fields={fields}
depth={depth + 1}
@@ -251,7 +255,7 @@ function FilterGroupView({ group, fields, depth, onChange, onRemove }: {
/>
) : (
<FilterRow
key={i}
key={filterNodeKey(child)}
condition={child}
fields={fields}
onUpdate={(c) => updateChild(i, c)}

View File

@@ -1,5 +1,5 @@
import { CaretUpDown } from '@phosphor-icons/react'
import { useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type RefObject } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type RefObject } from 'react'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { FilterFieldOptionsList } from './filter-builder/FilterFieldOptionsList'
@@ -276,33 +276,33 @@ export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComb
)
const options = useMemo(() => flattenGroups(fieldGroups), [fieldGroups])
const resetToCurrentValue = () => {
const resetToCurrentValue = useCallback(() => {
setQuery(value)
setHasTyped(false)
setHighlightedIndex(initialHighlightIndex({
options: flattenGroups(buildFieldGroups({ fields, currentValue: value, query: '' })),
currentValue: value,
}))
}
}, [fields, value])
const openCombobox = () => {
const openCombobox = useCallback(() => {
resetToCurrentValue()
setOpen(true)
requestAnimationFrame(() => inputRef.current?.select())
}
}, [resetToCurrentValue])
const closeCombobox = () => {
const closeCombobox = useCallback(() => {
setOpen(false)
resetToCurrentValue()
}
}, [resetToCurrentValue])
const selectOption = (nextValue: FilterFieldName) => {
const selectOption = useCallback((nextValue: FilterFieldName) => {
onChange(nextValue)
setQuery(nextValue)
setHasTyped(false)
setHighlightedIndex(-1)
setOpen(false)
}
}, [onChange])
useEffect(() => {
if (!open) return
@@ -317,11 +317,19 @@ export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComb
return () => window.removeEventListener('resize', updateWidth)
}, [open])
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
if (rootRef.current?.contains(event.relatedTarget as Node | null)) return
useEffect(() => {
const root = rootRef.current
if (!root) return
const handleBlur = (event: globalThis.FocusEvent) => {
if (root.contains(event.relatedTarget as Node | null)) return
closeCombobox()
}
root.addEventListener('focusout', handleBlur)
return () => root.removeEventListener('focusout', handleBlur)
}, [closeCombobox])
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const nextQuery = event.target.value
const nextGroups = buildFieldGroups({ fields, currentValue: value, query: nextQuery })
@@ -351,7 +359,6 @@ export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComb
<div
ref={rootRef}
className="relative flex-1 min-w-[160px]"
onBlur={handleBlur}
data-testid="filter-field-combobox"
>
<FilterFieldInput

View File

@@ -55,7 +55,7 @@ interface FolderTreeBodyProps extends Pick<
rootPath?: string
sectionCollapsed: boolean
toggleFolder: (path: string) => void
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLElement>) => void
}
function vaultRootLabel(vaultRootPath: string, locale: AppLocale): string {

View File

@@ -1,3 +1,4 @@
import { useId } from 'react'
import type { GitRepositoryOption } from '../utils/gitRepositories'
import {
Select,
@@ -22,13 +23,16 @@ export function GitRepositorySelect({
selectedPath,
testId,
}: GitRepositorySelectProps) {
const triggerId = useId()
if (repositories.length <= 1) return null
return (
<label className="flex min-w-0 items-center gap-2 text-[11px] font-medium text-muted-foreground">
<label htmlFor={triggerId} className="flex min-w-0 items-center gap-2 text-[11px] font-medium text-muted-foreground">
<span className="shrink-0">{label}</span>
<Select value={selectedPath} onValueChange={onChange}>
<SelectTrigger
id={triggerId}
size="sm"
className="h-7 min-w-0 max-w-44 flex-1 border-border bg-[var(--bg-input)] px-2 text-xs"
data-testid={testId}

View File

@@ -169,13 +169,14 @@ export function IconEditableValue({
}
return (
<span
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md px-2 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
<button
type="button"
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md border-0 bg-transparent px-2 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
data-testid="icon-editable-display"
>
{value || '\u2014'}
</span>
</button>
)
}

View File

@@ -225,6 +225,7 @@ export function InlineWikilinkInput({
const handledFileDropRef = useRef(false)
const pendingFocusAfterRemountRef = useRef<InlineSelectionRange | null>(null)
useLayoutEffect(() => {
void renderVersion
const target = pendingFocusAfterRemountRef.current
if (!target) return
pendingFocusAfterRemountRef.current = null
@@ -376,6 +377,7 @@ export function InlineWikilinkInput({
notifyUnsupportedPaste()
}, [disabled, insertTransferText, notifyUnsupportedPaste])
useLayoutEffect(() => {
void renderVersion
const editor = editorRef.current
if (!editor) return

View File

@@ -1,4 +1,4 @@
import { Fragment, createElement } from 'react'
import { Fragment, createElement, useEffect, useImperativeHandle, useRef } from 'react'
import type { CSSProperties } from 'react'
import type { VaultEntry } from '../types'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
@@ -11,6 +11,17 @@ import type {
import type { InlineWikilinkSuggestion } from './inlineWikilinkSuggestions'
import { cn } from '@/lib/utils'
function withNativeEvent<T extends Event>(event: T): T & { nativeEvent: T } {
const eventWithNativeEvent = event as T & { nativeEvent?: T }
if (!eventWithNativeEvent.nativeEvent) {
Object.defineProperty(event, 'nativeEvent', {
configurable: true,
value: event,
})
}
return event as T & { nativeEvent: T }
}
export function InlineWikilinkChipView({
chip,
typeEntryMap,
@@ -72,16 +83,17 @@ function InlineSuggestionRow({
const typeIcon = getTypeIcon(suggestion.entry.isA, typeEntry?.icon)
return (
<div
<button
type="button"
className={cn(
'mx-1 flex cursor-pointer items-center justify-between rounded-md px-3 py-2 transition-colors',
'mx-1 flex w-[calc(100%-0.5rem)] cursor-pointer items-center justify-between rounded-md border-0 bg-transparent px-3 py-2 text-left transition-colors',
selected ? 'bg-accent' : 'hover:bg-secondary',
)}
onMouseDown={(event) => event.preventDefault()}
onClick={onSelect}
onMouseEnter={onHover}
>
<div className="flex min-w-0 items-center gap-2">
<span className="flex min-w-0 items-center gap-2">
<span
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor, color }}
@@ -98,11 +110,11 @@ function InlineSuggestionRow({
)}
</span>
<span className="truncate text-sm text-foreground">{suggestion.title}</span>
</div>
</span>
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
{suggestion.entry.isA ?? 'Note'}
</span>
</div>
</button>
)
}
@@ -189,7 +201,20 @@ export function InlineWikilinkEditorField({
segments: InlineWikilinkSegment[]
typeEntryMap: Record<string, VaultEntry>
}) {
const editorRef = useRef<HTMLDivElement | null>(null)
const needsTrailingCaretAnchor = segments[segments.length - 1]?.kind === 'chip'
useImperativeHandle(inputRef, () => editorRef.current as HTMLDivElement, [])
useInlineWikilinkPlaceholder(editorRef, placeholder)
useInlineWikilinkEditorEvents(editorRef, {
onCompositionEnd,
onCompositionStart,
onCut,
onDrop,
onInput,
onKeyDown,
onPaste,
onSelectionChange,
})
return (
<div className="relative">
@@ -202,46 +227,103 @@ export function InlineWikilinkEditorField({
</div>
)}
<div
ref={inputRef}
ref={editorRef}
contentEditable={!disabled}
suppressContentEditableWarning={true}
role="textbox"
aria-multiline="true"
aria-disabled={disabled || undefined}
aria-placeholder={placeholder}
aria-disabled={disabled ? 'true' : undefined}
data-testid={dataTestId}
className={cn(
'min-h-[34px] w-full rounded-lg border border-border bg-transparent px-[10px] py-[8px] text-[13px] text-foreground outline-none',
disabled && 'cursor-not-allowed opacity-60',
editorClassName,
)}
onCompositionEnd={onCompositionEnd}
onCompositionStart={onCompositionStart}
onInput={onInput}
onKeyDown={onKeyDown}
onCut={onCut}
onDrop={onDrop}
onPaste={onPaste}
onClick={onSelectionChange}
onKeyUp={onSelectionChange}
onMouseUp={onSelectionChange}
style={{ ...editorStyle, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
>
{segments.map((segment, index) => (
segment.kind === 'text'
? <Fragment key={`text-${index}`}>{segment.text}</Fragment>
: (
{segments.map((segment) => renderInlineWikilinkSegment(segment, typeEntryMap))}
{needsTrailingCaretAnchor ? '\u200B' : null}
</div>
</div>
)
}
type InlineWikilinkEditorHandlers = Pick<
Parameters<typeof InlineWikilinkEditorField>[0],
| 'onCompositionEnd'
| 'onCompositionStart'
| 'onCut'
| 'onDrop'
| 'onInput'
| 'onKeyDown'
| 'onPaste'
| 'onSelectionChange'
>
function useInlineWikilinkPlaceholder(editorRef: React.RefObject<HTMLDivElement | null>, placeholder?: string) {
useEffect(() => {
const editor = editorRef.current
if (!editor) return
syncPlaceholderAttribute(editor, placeholder)
}, [editorRef, placeholder])
}
function syncPlaceholderAttribute(editor: HTMLDivElement, placeholder?: string) {
if (placeholder) {
editor.setAttribute('aria-placeholder', placeholder)
return
}
editor.removeAttribute('aria-placeholder')
}
function useInlineWikilinkEditorEvents(
editorRef: React.RefObject<HTMLDivElement | null>,
handlers: InlineWikilinkEditorHandlers,
) {
useEffect(() => {
const editor = editorRef.current
if (!editor) return
const listenerMap = inlineWikilinkEditorListenerMap(handlers)
for (const [eventName, listener] of listenerMap) editor.addEventListener(eventName, listener)
return () => {
for (const [eventName, listener] of listenerMap) editor.removeEventListener(eventName, listener)
}
}, [editorRef, handlers])
}
function inlineWikilinkEditorListenerMap({
onCompositionEnd,
onCompositionStart,
onCut,
onDrop,
onInput,
onKeyDown,
onPaste,
onSelectionChange,
}: InlineWikilinkEditorHandlers): Array<[keyof HTMLElementEventMap, EventListener]> {
const handleSelectionChange = () => onSelectionChange()
return [
['compositionstart', () => onCompositionStart()],
['compositionend', () => onCompositionEnd()],
['input', () => onInput()],
['keydown', (event) => onKeyDown(withNativeEvent(event) as unknown as React.KeyboardEvent<HTMLDivElement>)],
['cut', (event) => onCut(withNativeEvent(event) as unknown as React.ClipboardEvent<HTMLDivElement>)],
['drop', (event) => onDrop(withNativeEvent(event) as unknown as React.DragEvent<HTMLDivElement>)],
['paste', (event) => onPaste(withNativeEvent(event) as unknown as React.ClipboardEvent<HTMLDivElement>)],
['click', handleSelectionChange],
['keyup', handleSelectionChange],
['mouseup', handleSelectionChange],
]
}
function renderInlineWikilinkSegment(segment: InlineWikilinkSegment, typeEntryMap: Record<string, VaultEntry>) {
if (segment.kind === 'text') return <Fragment key={`text-${segment.text}`}>{segment.text}</Fragment>
return (
<InlineWikilinkChipView
key={`chip-${segment.chip.entry.path}-${segment.chip.target}`}
chip={segment.chip}
typeEntryMap={typeEntryMap}
/>
)
))}
{needsTrailingCaretAnchor ? '\u200B' : null}
</div>
</div>
)
}
export function InlineWikilinkPaletteLayout({

View File

@@ -21,7 +21,9 @@ vi.mock('@tauri-apps/api/window', () => ({
async function openSubmenu(label: string) {
fireEvent.pointerDown(screen.getByRole('button', { name: 'Application menu' }), { button: 0 })
const trigger = await screen.findByRole('menuitem', { name: new RegExp(label) })
const trigger = await screen.findByRole('menuitem', {
name: (accessibleName) => accessibleName.includes(label),
})
fireEvent.pointerMove(trigger)
fireEvent.click(trigger)
}

View File

@@ -47,12 +47,20 @@ function triggerMenuCommand(menuItemId: string): void {
void invoke('trigger_menu_command', { id: menuItemId }).catch(() => {})
}
function menuSeparatorKey(section: MenuSection, item: MenuItem): string {
const ordinal = section.items
.slice(0, section.items.indexOf(item) + 1)
.filter(candidate => candidate.kind === 'separator')
.length
return `${section.label}-separator-${ordinal}`
}
function MenuSectionItems({ section }: { section: MenuSection }) {
return (
<>
{section.items.map((item, index) => {
{section.items.map((item) => {
if (item.kind === 'separator') {
return <DropdownMenuSeparator key={`${section.label}-${index}`} />
return <DropdownMenuSeparator key={menuSeparatorKey(section, item)} />
}
if (item.kind === 'command') {
@@ -82,7 +90,7 @@ function MenuSectionItems({ section }: { section: MenuSection }) {
function HamburgerIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<line x1="2" y1="4" x2="12" y2="4" />
<line x1="2" y1="7" x2="12" y2="7" />
<line x1="2" y1="10" x2="12" y2="10" />

View File

@@ -37,7 +37,7 @@ const RESIZE_HANDLES: ReadonlyArray<{
export function LinuxTitlebar() {
const customChromeEnabled = shouldUseCustomWindowChrome()
const { onMouseDown } = useDragRegion()
const { dragRegionRef } = useDragRegion<HTMLDivElement>()
const maximized = useLinuxMaximizedState(customChromeEnabled)
if (!customChromeEnabled) return null
@@ -48,9 +48,9 @@ export function LinuxTitlebar() {
<>
<ResizeHandles />
<div
ref={dragRegionRef}
className="fixed top-0 right-0 left-0 z-[1000] flex items-center justify-between border-b border-border bg-background select-none"
style={{ height: LINUX_TITLEBAR_HEIGHT }}
onMouseDown={onMouseDown}
data-testid="linux-titlebar"
>
<div className="flex h-full items-center" data-no-drag>
@@ -175,7 +175,7 @@ function TitlebarButton({
function MinimizeIcon() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round">
<line x1="2.5" y1="6" x2="9.5" y2="6" />
</svg>
)
@@ -183,7 +183,7 @@ function MinimizeIcon() {
function MaximizeIcon() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
<rect x="2.5" y="2.5" width="7" height="7" rx="0.5" />
</svg>
)
@@ -191,7 +191,7 @@ function MaximizeIcon() {
function RestoreIcon() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
<rect x="2.5" y="3.8" width="6" height="6" rx="0.5" />
<path d="M4 3.8 V 2.5 H 9.5 V 8" />
</svg>
@@ -200,7 +200,7 @@ function RestoreIcon() {
function CloseIcon() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round">
<line x1="3" y1="3" x2="9" y2="9" />
<line x1="9" y1="3" x2="3" y2="9" />
</svg>

View File

@@ -1,4 +1,4 @@
import { memo, useMemo, useCallback, type MouseEvent } from 'react'
import { memo, useMemo } from 'react'
import Markdown, { defaultUrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
@@ -24,14 +24,6 @@ export const MarkdownContent = memo(function MarkdownContent({ content, onWikili
[content, onWikilinkClick],
)
const handleClick = useCallback((e: MouseEvent) => {
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-wikilink-target]')
if (el) {
e.preventDefault()
onWikilinkClick?.(el.dataset.wikilinkTarget!)
}
}, [onWikilinkClick])
const components = useMemo(() => {
if (!onWikilinkClick) return undefined
return {
@@ -39,9 +31,21 @@ export const MarkdownContent = memo(function MarkdownContent({ content, onWikili
if (href?.startsWith(WIKILINK_SCHEME)) {
const target = decodeURIComponent(href.slice(WIKILINK_SCHEME.length))
return (
<span className="chat-wikilink" data-wikilink-target={target} role="link" tabIndex={0}>
<a
ref={(node) => {
node?.setAttribute('role', 'link')
node?.setAttribute('tabindex', '0')
}}
href={href}
className="chat-wikilink border-0 bg-transparent p-0"
data-wikilink-target={target}
onClick={(event) => {
event.preventDefault()
onWikilinkClick(target)
}}
>
{children}
</span>
</a>
)
}
return <a href={href}>{children}</a>
@@ -50,7 +54,7 @@ export const MarkdownContent = memo(function MarkdownContent({ content, onWikili
}, [onWikilinkClick])
return (
<div className="ai-markdown" onClick={onWikilinkClick ? handleClick : undefined} role="presentation">
<div className="ai-markdown">
<Markdown
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}

View File

@@ -97,7 +97,6 @@ function ManualMcpConfigSection(props: ManualMcpConfigSectionProps) {
</Button>
</div>
<pre
tabIndex={0}
className="max-h-48 overflow-auto rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground"
data-testid="mcp-config-snippet"
>

View File

@@ -62,12 +62,12 @@ describe('MermaidDiagram', () => {
it('keeps rendered SVG pointer events inside the Mermaid block', async () => {
const onBlockPointer = vi.fn()
render(
<div onClick={onBlockPointer} onMouseDown={onBlockPointer}>
<button type="button" onClick={onBlockPointer} onMouseDown={onBlockPointer}>
<MermaidDiagram
diagram={'flowchart LR\nA --> B'}
source={'```mermaid\nflowchart LR\nA --> B\n```'}
/>
</div>,
</button>,
)
await waitFor(() => {

View File

@@ -1,5 +1,5 @@
import { ArrowsOut as Maximize2 } from '@phosphor-icons/react'
import { useEffect, useId, useMemo, useState, type SyntheticEvent } from 'react'
import { useEffect, useId, useMemo, useRef, useState, type SyntheticEvent } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -158,6 +158,16 @@ function MermaidLightbox({ svg }: { svg: string }) {
)
}
function MermaidSourceFallback({ source }: { source: string }) {
const sourceRef = useRef<HTMLPreElement>(null)
useEffect(() => {
sourceRef.current?.setAttribute('aria-label', 'Mermaid source')
}, [])
return <pre ref={sourceRef}><code>{source}</code></pre>
}
export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
const reactId = useId()
const renderId = useMemo(() => renderIdFromReactId(reactId), [reactId])
@@ -183,7 +193,7 @@ export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
return (
<figure className="mermaid-diagram mermaid-diagram--error" data-testid="mermaid-diagram-error">
<figcaption>Mermaid diagram unavailable</figcaption>
<pre aria-label="Mermaid source"><code>{source}</code></pre>
<MermaidSourceFallback source={source} />
</figure>
)
}

View File

@@ -207,7 +207,8 @@ function NoteAutocompleteMenuItem({
onHover,
}: NoteAutocompleteMenuItemProps) {
return (
<div
<button
type="button"
className={`wikilink-menu__item${selected ? ' wikilink-menu__item--selected' : ''}`}
onMouseDown={preventAutocompleteMouseDown}
onClick={() => onSelect(item.title)}
@@ -224,7 +225,7 @@ function NoteAutocompleteMenuItem({
</span>
)}
<WorkspaceInitialsBadge workspace={item.workspace} testId="note-autocomplete-workspace-badge" />
</div>
</button>
)
}

View File

@@ -27,7 +27,7 @@ export function NoteIcon({ icon, editable = true }: NoteIconProps) {
return (
<div className="note-icon-area" data-testid="note-icon-area" style={{ position: 'relative' }}>
{hasIcon ? (
<button
<button type="button"
className="note-icon-button note-icon-button--active"
onClick={handleActivate}
data-testid="note-icon-display"
@@ -38,7 +38,7 @@ export function NoteIcon({ icon, editable = true }: NoteIconProps) {
</button>
) : (
editable && (
<button
<button type="button"
className="note-icon-button note-icon-button--add"
onClick={handleActivate}
data-testid="note-icon-add"

View File

@@ -90,14 +90,14 @@ type NoteItemRowState = 'binary' | 'multiSelected' | 'selected' | 'highlighted'
type NoteItemSurfaceProps = {
className: string
style: CSSProperties
onClick: MouseEventHandler<HTMLDivElement>
onContextMenu?: MouseEventHandler<HTMLDivElement>
onClick: MouseEventHandler<HTMLButtonElement>
onContextMenu?: MouseEventHandler<HTMLButtonElement>
onMouseEnter?: () => void
title?: string
testId?: string
}
const NOTE_ITEM_BASE_CLASS_NAME = 'relative border-b border-[var(--border)] transition-colors'
const NOTE_ITEM_BASE_CLASS_NAME = 'relative w-full border-0 border-b border-[var(--border)] bg-transparent p-0 text-left transition-colors'
const BINARY_NOTE_STYLE: CSSProperties = { padding: '14px 16px' }
const NOTE_ITEM_ROW_CLASS_NAMES: Record<NoteItemRowState, string> = {
binary: 'cursor-default opacity-50',
@@ -369,13 +369,19 @@ function createNoteItemClickHandler(
isUnavailableBinary: boolean,
onClickNote: NoteItemProps['onClickNote'],
) {
const isPropertyChipTarget = (event: ReactMouseEvent) =>
event.target instanceof Element && event.target.closest('[data-property-chip="true"]') !== null
if (isUnavailableBinary) {
return (event: ReactMouseEvent) => {
event.preventDefault()
event.stopPropagation()
}
}
return (event: ReactMouseEvent) => onClickNote(entry, event)
return (event: ReactMouseEvent) => {
if (isPropertyChipTarget(event)) return
onClickNote(entry, event)
}
}
function resolveNoteItemSurfaceStyle({
@@ -463,7 +469,8 @@ function NoteItemRow({
children: ReactNode
}) {
return (
<div
<button
type="button"
className={surfaceProps.className}
style={surfaceProps.style}
onClick={surfaceProps.onClick}
@@ -476,7 +483,7 @@ function NoteItemRow({
title={surfaceProps.title}
>
{children}
</div>
</button>
)
}

View File

@@ -46,7 +46,7 @@ function NoteSearchListItem<T extends NoteSearchResultItem>({
}: NoteSearchListItemProps<T>) {
const pressActivatedRef = useRef(false)
const activateFromPress = (event: MouseEvent<HTMLDivElement> | PointerEvent<HTMLDivElement>) => {
const activateFromPress = (event: MouseEvent<HTMLButtonElement> | PointerEvent<HTMLButtonElement>) => {
event.preventDefault()
if (!activateOnMouseDown) return
@@ -60,7 +60,7 @@ function NoteSearchListItem<T extends NoteSearchResultItem>({
onItemClick(item, index)
}
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (activateOnMouseDown) {
event.preventDefault()
event.stopPropagation()
@@ -73,9 +73,13 @@ function NoteSearchListItem<T extends NoteSearchResultItem>({
return (
<div
className={cn(
'flex cursor-pointer items-center justify-between gap-2 px-3 py-1.5 transition-colors',
'flex cursor-pointer items-center justify-between gap-2 transition-colors',
selected ? 'bg-accent' : 'hover:bg-secondary',
)}
>
<button
type="button"
className="flex w-full items-center justify-between gap-2 border-0 bg-transparent px-3 py-1.5 text-left"
onPointerDownCapture={activateFromPress}
onMouseDownCapture={activateFromPress}
onClick={handleClick}
@@ -107,6 +111,7 @@ function NoteSearchListItem<T extends NoteSearchResultItem>({
<WorkspaceInitialsBadge workspace={item.workspace} testId="note-search-workspace-badge" />
</span>
)}
</button>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import type { CSSProperties, ReactNode } from 'react'
import { useEffect, useRef, type CSSProperties, type ReactNode, type RefObject } from 'react'
import { cn } from '@/lib/utils'
import { useDragRegion } from '../hooks/useDragRegion'
@@ -19,14 +19,28 @@ export function OnboardingShell({
style,
testId,
}: OnboardingShellProps) {
const { onMouseDown } = useDragRegion()
type DragRegionResult = ReturnType<typeof useDragRegion<HTMLDivElement>> & {
dragRegionRef?: RefObject<HTMLDivElement | null>
}
const { dragRegionRef, onMouseDown } = useDragRegion<HTMLDivElement>() as DragRegionResult
const fallbackDragRegionRef = useRef<HTMLDivElement>(null)
const shellRef = dragRegionRef ?? fallbackDragRegionRef
useEffect(() => {
if (dragRegionRef) return
const shell = fallbackDragRegionRef.current
if (!shell) return
shell.addEventListener('mousedown', onMouseDown)
return () => shell.removeEventListener('mousedown', onMouseDown)
}, [dragRegionRef, onMouseDown])
return (
<div
ref={shellRef}
className={cn('flex h-full w-full items-center justify-center px-6 py-8', className)}
style={style}
data-testid={testId}
onMouseDown={onMouseDown}
>
<div className={contentClassName} style={contentStyle} data-no-drag>
{children}

View File

@@ -1,3 +1,4 @@
import { useId } from 'react'
import type { createTranslator } from '../lib/i18n'
import { SectionHeading, SettingsGroup, SettingsGroupItem } from './SettingsControls'
import { Checkbox } from './ui/checkbox'
@@ -29,10 +30,12 @@ function TelemetryToggle({
onChange: (value: boolean) => void
testId: string
}) {
const checkboxId = useId()
return (
<SettingsGroupItem testId={testId}>
<label className="flex cursor-pointer items-start gap-3">
<Checkbox checked={checked} onCheckedChange={(value) => onChange(isChecked(value))} className="mt-0.5" />
<label htmlFor={checkboxId} className="flex cursor-pointer items-start gap-3">
<Checkbox id={checkboxId} checked={checked} onCheckedChange={(value) => onChange(isChecked(value))} className="mt-0.5" />
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">{label}</span>
<span className="block text-xs leading-5 text-muted-foreground">{description}</span>

View File

@@ -7,7 +7,7 @@ const { createValueButtonMock, calendarMockState } = vi.hoisted(() => ({
createValueButtonMock:
(testId: string, nextValue: (value: string) => string) =>
({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid={testId} onClick={() => onSave(nextValue(value))}>
<button type="button" data-testid={testId} onClick={() => onSave(nextValue(value))}>
{value}
</button>
),
@@ -32,7 +32,7 @@ vi.mock('./EditableValue', () => ({
label: string
onSave: (items: string[]) => void
}) => (
<button data-testid="tag-pill-list" onClick={() => onSave([...items, 'omega'])}>
<button type="button" data-testid="tag-pill-list" onClick={() => onSave([...items, 'omega'])}>
{label}:{items.join(',')}
</button>
),
@@ -48,8 +48,8 @@ vi.mock('./StatusDropdown', () => ({
onCancel: () => void
}) => (
<div>
<button data-testid="status-save" onClick={() => onSave('Done')}>save</button>
<button data-testid="status-cancel" onClick={onCancel}>cancel</button>
<button type="button" data-testid="status-save" onClick={() => onSave('Done')}>save</button>
<button type="button" data-testid="status-cancel" onClick={onCancel}>cancel</button>
</div>
),
}))
@@ -63,9 +63,9 @@ vi.mock('./TagsDropdown', () => ({
onClose: () => void
}) => (
<div data-testid="tags-dropdown">
<button data-testid="tags-toggle-alpha" onClick={() => onToggle('alpha')}>alpha</button>
<button data-testid="tags-toggle-beta" onClick={() => onToggle('beta')}>beta</button>
<button data-testid="tags-close" onClick={onClose}>close</button>
<button type="button" data-testid="tags-toggle-alpha" onClick={() => onToggle('alpha')}>alpha</button>
<button type="button" data-testid="tags-toggle-beta" onClick={() => onToggle('beta')}>beta</button>
<button type="button" data-testid="tags-close" onClick={onClose}>close</button>
</div>
),
}))
@@ -95,10 +95,10 @@ vi.mock('@/components/ui/calendar', () => ({
calendarMockState.props.push({ captionLayout, navLayout, startMonth, endMonth })
return (
<div>
<button data-testid="date-picker-calendar" onClick={() => onSelect(new Date(2026, 3, 23))}>
<button type="button" data-testid="date-picker-calendar" onClick={() => onSelect(new Date(2026, 3, 23))}>
pick
</button>
<button data-testid="date-picker-empty" onClick={() => onSelect(undefined)}>
<button type="button" data-testid="date-picker-empty" onClick={() => onSelect(undefined)}>
empty
</button>
</div>
@@ -116,7 +116,7 @@ vi.mock('@/components/ui/popover', () => ({
}) => (
<div>
{children}
<button data-testid="popover-close" onClick={() => onOpenChange?.(false)}>close</button>
<button type="button" data-testid="popover-close" onClick={() => onOpenChange?.(false)}>close</button>
</div>
),
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,

View File

@@ -8,7 +8,7 @@ const { createDropdownModule, createPrimarySecondaryActions } = vi.hoisted(() =>
) => (
<div data-testid={containerTestId}>
{actions.map(({ testId, label, onClick }) => (
<button key={testId} data-testid={testId} onClick={onClick}>
<button type="button" key={testId} data-testid={testId} onClick={onClick}>
{label}
</button>
))}
@@ -38,7 +38,7 @@ const { createDropdownModule, createPrimarySecondaryActions } = vi.hoisted(() =>
vi.mock('./EditableValue', () => ({
EditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="editable-value" onClick={() => onSave(`${value}-saved`)}>
<button type="button" data-testid="editable-value" onClick={() => onSave(`${value}-saved`)}>
{value}
</button>
),
@@ -51,12 +51,12 @@ vi.mock('./EditableValue', () => ({
label: string
onSave: (items: string[]) => void
}) => (
<button data-testid="tag-pill-list" onClick={() => onSave([...items, 'gamma'])}>
<button type="button" data-testid="tag-pill-list" onClick={() => onSave([...items, 'gamma'])}>
{label}:{items.join(',')}
</button>
),
UrlValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="url-value" onClick={() => onSave('https://saved.example')}>
<button type="button" data-testid="url-value" onClick={() => onSave('https://saved.example')}>
{value}
</button>
),
@@ -80,7 +80,7 @@ vi.mock('./TagsDropdown', () =>
vi.mock('./ColorInput', () => ({
ColorEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="color-value" onClick={() => onSave('#00ff00')}>
<button type="button" data-testid="color-value" onClick={() => onSave('#00ff00')}>
{value}
</button>
),
@@ -88,7 +88,7 @@ vi.mock('./ColorInput', () => ({
vi.mock('./IconEditableValue', () => ({
IconEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="icon-value" onClick={() => onSave('sparkles')}>
<button type="button" data-testid="icon-value" onClick={() => onSave('sparkles')}>
{value}
</button>
),
@@ -96,7 +96,7 @@ vi.mock('./IconEditableValue', () => ({
vi.mock('@/components/ui/calendar', () => ({
Calendar: ({ onSelect }: { onSelect: (value: Date) => void }) => (
<button
<button type="button"
data-testid="date-picker-calendar"
onClick={() => onSelect(new Date(2026, 3, 22))}
>

View File

@@ -1,5 +1,5 @@
import { ArrowUpRight, X as XIcon } from '@phosphor-icons/react'
import { useState, useCallback, useRef, type ReactNode } from 'react'
import { useState, useCallback, useEffect, useRef, type ReactNode, type RefObject } from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
@@ -104,15 +104,16 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
const style = getStatusStyle(statusStr)
return (
<span className="relative inline-flex min-w-0 items-center">
<span
className="inline-flex cursor-pointer items-center gap-1.5 transition-opacity hover:opacity-80"
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1.5 border-0 transition-opacity hover:opacity-80"
style={{ ...PROPERTY_CHIP_STYLE, backgroundColor: style.bg, color: style.color }}
onClick={() => onStartEdit(propKey)}
data-testid="status-badge"
>
<span className="inline-block size-1.5 shrink-0 rounded-full" style={{ backgroundColor: style.color }} />
{statusStr}
</span>
</button>
{isEditing && (
<StatusDropdown
value={statusStr}
@@ -159,7 +160,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
>
{tag}
</span>
<button
<button type="button"
className="ml-0.5 max-w-0 overflow-hidden border-none bg-transparent p-0 leading-none opacity-0 transition-all duration-150 group-hover/tag:max-w-[14px] group-hover/tag:opacity-100"
style={{ color: style.color, fontSize: 11, flexShrink: 0 }}
onClick={() => handleRemove(tag)}
@@ -170,7 +171,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
</span>
)
})}
<button
<button type="button"
className="inline-flex shrink-0 items-center justify-center border-none bg-muted text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={PROPERTY_CHIP_STYLE}
onClick={() => onStartEdit(propKey)}
@@ -430,24 +431,8 @@ export function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }
const CurrentIcon = Reflect.get(DISPLAY_MODE_ICONS, currentMode) as typeof DISPLAY_MODE_ICONS.text
const showRelationshipIcon = showsRelationshipPropertyIcon(propKey)
const positionMenu = useCallback((node: HTMLDivElement | null) => {
if (!node) return
const el = triggerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const menuW = 140
let left = rect.right - menuW
if (left < 8) left = 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
const handleSelect = (mode: PropertyDisplayMode) => {
if (mode === autoMode) {
onSelect(propKey, null)
} else {
onSelect(propKey, mode)
}
onSelect(propKey, mode === autoMode ? null : mode)
setOpen(false)
}
@@ -468,40 +453,107 @@ export function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }
<CurrentIcon className="size-3.5" data-testid={`display-mode-icon-${currentMode}`} />
)}
</button>
{open && createPortal(
{open && (
<DisplayModeMenu
autoMode={autoMode}
currentMode={currentMode}
onClose={() => setOpen(false)}
onSelect={handleSelect}
triggerRef={triggerRef}
/>
)}
</div>
)
}
function DisplayModeMenu({
autoMode,
currentMode,
onClose,
onSelect,
triggerRef,
}: {
autoMode: PropertyDisplayMode
currentMode: PropertyDisplayMode
onClose: () => void
onSelect: (mode: PropertyDisplayMode) => void
triggerRef: RefObject<HTMLButtonElement | null>
}) {
const backdropRef = useRef<HTMLDivElement>(null)
const positionMenu = useCallback((node: HTMLDivElement | null) => {
if (!node) return
const el = triggerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const menuW = 140
const left = Math.max(rect.right - menuW, 8)
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [triggerRef])
useEffect(() => {
const backdrop = backdropRef.current
if (!backdrop) return
backdrop.addEventListener('click', onClose)
return () => backdrop.removeEventListener('click', onClose)
}, [onClose])
return createPortal(
<>
<div className="fixed inset-0 z-[12000]" onClick={() => setOpen(false)} />
<div
ref={backdropRef}
className="fixed inset-0 z-[12000]"
/>
<div
ref={positionMenu}
className="fixed z-[12001] min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
data-testid="display-mode-menu"
>
{DISPLAY_MODE_OPTIONS.map(opt => {
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
{DISPLAY_MODE_OPTIONS.map((opt) => (
<DisplayModeOption
key={opt.value}
autoMode={autoMode}
currentMode={currentMode}
option={opt}
onSelect={onSelect}
/>
))}
</div>
</>,
document.body,
)
}
function DisplayModeOption({
autoMode,
currentMode,
onSelect,
option,
}: {
autoMode: PropertyDisplayMode
currentMode: PropertyDisplayMode
onSelect: (mode: PropertyDisplayMode) => void
option: typeof DISPLAY_MODE_OPTIONS[number]
}) {
const OptIcon = DISPLAY_MODE_ICONS[option.value]
return (
<button
key={opt.value}
type="button"
className="flex w-full items-center gap-2 border-none bg-transparent px-3 py-1.5 text-left text-[12px] text-foreground transition-colors hover:bg-muted"
onClick={() => handleSelect(opt.value)}
data-testid={`display-mode-option-${opt.value}`}
onClick={() => onSelect(option.value)}
data-testid={`display-mode-option-${option.value}`}
>
<span className="w-3 text-center text-[10px]">
{currentMode === opt.value ? '\u2713' : ''}
{currentMode === option.value ? '\u2713' : ''}
</span>
<OptIcon className="size-3.5 text-muted-foreground" />
{opt.label}
{opt.value === autoMode && (
{option.label}
{option.value === autoMode && (
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
)}
</button>
)
})}
</div>
</>,
document.body
)}
</div>
)
}
function toBooleanValue(value: FrontmatterValue): boolean {

View File

@@ -133,7 +133,9 @@ describe('PulseView', () => {
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
screen.getAllByLabelText('Expand files').forEach((btn) => fireEvent.click(btn))
screen.getAllByLabelText('Expand files').forEach((btn) => {
fireEvent.click(btn)
})
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
@@ -164,7 +166,9 @@ describe('PulseView', () => {
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
rowLabels.forEach((rowLabel) => fireEvent.click(screen.getByText(rowLabel)))
rowLabels.forEach((rowLabel) => {
fireEvent.click(screen.getByText(rowLabel))
})
await waitFor(() => {
expect(screen.getByText(fileText)).toBeInTheDocument()

View File

@@ -152,22 +152,18 @@ function CommitCard({
return (
<div className="border-b border-border px-4 py-2">
<div
<div className="flex items-start" style={{ gap: 8 }}>
<button
type="button"
className={cn(
'flex cursor-pointer items-start justify-between py-2 transition-colors focus-visible:bg-accent/40',
'flex min-w-0 flex-1 items-start justify-between border-none bg-transparent py-2 text-left transition-colors',
PULSE_ROW_FOCUS_CLASS_NAME,
PULSE_EDGE_TO_EDGE_ROW_CLASS_NAME,
expanded ? 'bg-accent/40' : 'hover:bg-accent/40',
expanded ? 'bg-accent/40' : 'hover:bg-accent/40 focus-visible:bg-accent/40',
)}
style={{ gap: 8 }}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={toggleExpanded}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return
handleActivationKey(event, toggleExpanded)
}}
onKeyDown={(event) => handleActivationKey(event, toggleExpanded)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center" style={{ gap: 6, marginBottom: 2 }}>
@@ -176,14 +172,20 @@ function CommitCard({
</div>
<div className="flex items-center" style={{ gap: 8 }}>
<span className="text-[11px] text-muted-foreground">{relativeDate(commit.date)}</span>
{commitUrl ? (
{!commitUrl && (
<span className="text-[11px] font-mono text-muted-foreground">{commit.shortHash}</span>
)}
<SummaryBadges added={commit.added} modified={commit.modified} deleted={commit.deleted} />
</div>
</div>
</button>
{commitUrl && (
<a
className="flex items-center text-[11px] font-mono text-primary no-underline hover:underline"
className="mt-2 flex shrink-0 items-center text-[11px] font-mono text-primary no-underline hover:underline"
style={{ gap: 3 }}
href={commitUrl}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void openExternalUrl(commitUrl)
}}
title={translate(locale, 'pulse.openOnGitHub')}
@@ -191,20 +193,12 @@ function CommitCard({
{commit.shortHash}
<ArrowSquareOut size={10} />
</a>
) : (
<span className="text-[11px] font-mono text-muted-foreground">{commit.shortHash}</span>
)}
<SummaryBadges added={commit.added} modified={commit.modified} deleted={commit.deleted} />
</div>
</div>
<button
type="button"
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground hover:text-foreground"
className="mt-2 flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground hover:text-foreground"
style={{ width: 20, height: 20 }}
onClick={(event) => {
event.stopPropagation()
toggleExpanded()
}}
onClick={toggleExpanded}
aria-label={translate(locale, expanded ? 'pulse.collapseFiles' : 'pulse.expandFiles')}
>
<Chevron size={12} />
@@ -233,14 +227,13 @@ function DayGroup({ label, commits, locale, onOpenNote }: {
return (
<div>
<div
<button
type="button"
className={cn(
'flex cursor-pointer select-none items-center border-b border-border bg-muted/50 transition-colors hover:bg-muted focus-visible:bg-muted',
'flex w-full cursor-pointer select-none items-center border-0 border-b border-border bg-muted/50 text-left transition-colors hover:bg-muted focus-visible:bg-muted',
PULSE_ROW_FOCUS_CLASS_NAME,
)}
style={{ padding: '6px 16px', gap: 6 }}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={toggleCollapsed}
onKeyDown={(event) => handleActivationKey(event, toggleCollapsed)}
@@ -255,7 +248,7 @@ function DayGroup({ label, commits, locale, onOpenNote }: {
label: translate(locale, commits.length === 1 ? 'pulse.commitSingular' : 'pulse.commitPlural'),
})})
</span>
</div>
</button>
{!collapsed && commits.map((commit) => (
<CommitCard key={commit.hash} commit={commit} locale={locale} onOpenNote={onOpenNote} />
))}
@@ -268,13 +261,27 @@ function PulseHeader({
onExpandSidebar,
locale = 'en',
}: Pick<PulseViewProps, 'sidebarCollapsed' | 'onExpandSidebar' | 'locale'>) {
const { onMouseDown } = useDragRegion()
type DragRegionResult = ReturnType<typeof useDragRegion<HTMLDivElement>> & {
dragRegionRef?: React.RefObject<HTMLDivElement | null>
}
const { dragRegionRef, onMouseDown } = useDragRegion<HTMLDivElement>() as DragRegionResult
const fallbackDragRegionRef = useRef<HTMLDivElement>(null)
const headerRef = dragRegionRef ?? fallbackDragRegionRef
useEffect(() => {
if (dragRegionRef) return
const header = fallbackDragRegionRef.current
if (!header) return
header.addEventListener('mousedown', onMouseDown)
return () => header.removeEventListener('mousedown', onMouseDown)
}, [dragRegionRef, onMouseDown])
return (
<div
ref={headerRef}
className="flex shrink-0 items-center justify-between border-b border-border"
style={{ height: 52, padding: '0 16px', cursor: 'default' }}
onMouseDown={onMouseDown}
data-testid="pulse-header"
>
<div className="flex items-center" style={{ gap: 8 }}>
@@ -333,7 +340,7 @@ function ErrorState({ message, locale = 'en', onRetry }: { message: string; loca
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<p className="text-[13px]">{message}</p>
<button
<button type="button"
className="mt-2 cursor-pointer rounded border border-border bg-transparent px-3 py-1 text-[12px] text-foreground transition-colors hover:bg-accent"
onClick={onRetry}
>

View File

@@ -111,6 +111,7 @@ function useQuickOpenKeyboard({
export function QuickOpenPalette({ open, entries, isLoading = false, onSelect, onCreateNote, onClose, locale = 'en' }: QuickOpenPaletteProps) {
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const rootRef = useRef<HTMLDivElement>(null)
const { results, selectedIndex, setSelectedIndex, handleKeyDown } = useNoteSearch(entries, query)
const createAction = useQuickOpenCreateAction({ query, isLoading, resultCount: results.length, onCreateNote, onClose })
@@ -125,17 +126,35 @@ export function QuickOpenPalette({ open, entries, isLoading = false, onSelect, o
useQuickOpenKeyboard({ open, results, selectedIndex, onSelect, onClose, handleKeyDown, createFromQuery: createAction.create })
useEffect(() => {
if (!open) return
const root = rootRef.current
if (!root) return
const handleRootClick = (event: MouseEvent) => {
if (event.target === root) onClose()
}
root.addEventListener('click', handleRootClick)
return () => root.removeEventListener('click', handleRootClick)
}, [open, onClose])
if (!open) return null
return (
<div
ref={rootRef}
data-testid="quick-open-palette"
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
onClick={onClose}
>
<button
type="button"
aria-label="Close quick open"
className="absolute inset-0 z-0 cursor-default border-0 bg-transparent p-0"
onClick={onClose}
/>
<div
className="flex w-[500px] max-w-[90vw] max-h-[400px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
onClick={(e) => e.stopPropagation()}
className="relative z-10 flex w-[500px] max-w-[90vw] max-h-[400px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
>
<Input
ref={inputRef}

View File

@@ -109,16 +109,6 @@ function handleRawEditorFindKeyDown(
moveMatch(event.shiftKey ? -1 : 1)
}
function handleRawEditorFindBarKeyDown(
event: React.KeyboardEvent<HTMLDivElement>,
close: () => void,
): void {
if (event.key !== 'Escape') return
event.preventDefault()
close()
}
function selectActiveEditorFindMatch(
viewRef: React.MutableRefObject<EditorView | null>,
open: boolean,
@@ -180,7 +170,6 @@ interface RawEditorFindController {
caseSensitive: boolean
close: () => void
findInputRef: React.RefObject<HTMLInputElement | null>
handleBarKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
handleFindChange: (event: React.ChangeEvent<HTMLInputElement>) => void
handleFindKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
hasMatches: boolean
@@ -242,10 +231,6 @@ function useRawEditorFindController({
handleRawEditorFindKeyDown(event, close, moveMatch)
}, [close, moveMatch])
const handleBarKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
handleRawEditorFindBarKeyDown(event, close)
}, [close])
const replaceCurrent = useCallback(() => {
replaceCurrentEditorFindMatch({ activeMatch, options, query, replacement, viewRef })
}, [activeMatch, options, query, replacement, viewRef])
@@ -266,7 +251,6 @@ function useRawEditorFindController({
caseSensitive,
close,
findInputRef: inputRef,
handleBarKeyDown,
handleFindChange,
handleFindKeyDown,
hasMatches,
@@ -461,12 +445,12 @@ function ReplaceControls({
export function RawEditorFindBar(props: RawEditorFindBarProps) {
const { locale = 'en', onReplaceOpenChange, open, replaceOpen } = props
const barRef = useRef<HTMLDivElement>(null)
const controller = useRawEditorFindController(props)
const {
caseSensitive,
close,
findInputRef,
handleBarKeyDown,
handleFindChange,
handleFindKeyDown,
hasMatches,
@@ -483,13 +467,28 @@ export function RawEditorFindBar(props: RawEditorFindBarProps) {
toggleRegex,
} = controller
useEffect(() => {
if (!open) return
const bar = barRef.current
if (!bar) return
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Escape') return
event.preventDefault()
close()
}
bar.addEventListener('keydown', handleKeyDown)
return () => bar.removeEventListener('keydown', handleKeyDown)
}, [close, open])
if (!open) return null
return (
<div
ref={barRef}
className="flex shrink-0 flex-col gap-1.5 border-b px-3 py-2"
data-testid="raw-editor-find-bar"
onKeyDown={handleBarKeyDown}
style={{
background: 'var(--surface-editor)',
borderColor: 'var(--border-subtle)',

View File

@@ -60,7 +60,7 @@ vi.mock('./NoteSearchList', () => ({
return (
<div data-testid="note-search-list">
{props.items.map((item, index) => (
<button
<button type="button"
key={item.title}
data-testid={`note-search-item-${index}`}
onMouseEnter={() => props.onItemHover(index)}

View File

@@ -78,7 +78,7 @@ vi.mock('./NoteSearchList', () => ({
}) => (
<div data-testid="raw-editor-note-search-list">
{items.map((item, index) => (
<button
<button type="button"
key={item.title}
data-testid={`autocomplete-item-${index}`}
data-selected={index === selectedIndex}

View File

@@ -15,7 +15,7 @@ import {
} from '../utils/rawEditorUtils'
import { useCodeMirror } from '../hooks/useCodeMirror'
import type { VaultEntry } from '../types'
import { translate, type AppLocale } from '../lib/i18n'
import { type AppLocale } from '../lib/i18n'
import { RawEditorFindBar, type RawEditorFindRequest } from './RawEditorFindBar'
import {
activatePlainTextPasteTarget,
@@ -164,7 +164,7 @@ function useRawEditorPendingChanges({
debounceRef.current = setTimeout(() => {
onContentChangeRef.current(pathRef.current, doc)
}, DEBOUNCE_MS)
}, [latestContentRefStable, onContentChangeRef, pathRef])
}, [onContentChangeRef, pathRef])
const handleSave = useCallback(() => {
flushPendingRawEditorChange({ debounceRef, latestDocRef, onContentChangeRef, pathRef })
@@ -243,7 +243,7 @@ function useRawEditorAutocompleteKeyboard(
autocomplete: RawEditorAutocompleteState | null,
setAutocomplete: RawEditorSetAutocomplete,
) {
return useCallback((e: React.KeyboardEvent) => {
return useCallback((e: Pick<KeyboardEvent, 'key' | 'preventDefault'>) => {
if (!autocomplete) return
if (e.key === 'Enter') {
@@ -393,6 +393,7 @@ function useRawEditorPlainTextPasteTarget({
}
export function RawEditorView({ content, path, entries, sourceEntry, onContentChange, onSave, latestContentRef, vaultPath, locale = 'en', findRequest }: RawEditorViewProps) {
const rootRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [rawDoc, setRawDoc] = useState(content)
const [findOpen, setFindOpen] = useState(false)
@@ -429,6 +430,22 @@ export function RawEditorView({ content, path, entries, sourceEntry, onContentCh
setAutocomplete,
viewRef,
})
useEffect(() => {
const root = rootRef.current
if (!root) return
const activatePasteTarget = () => activatePlainTextPaste()
const handleKeyDown = (event: KeyboardEvent) => handleAutocompleteKey(event)
root.addEventListener('focusin', activatePasteTarget)
root.addEventListener('mousedown', activatePasteTarget, { capture: true })
root.addEventListener('keydown', handleKeyDown)
return () => {
root.removeEventListener('focusin', activatePasteTarget)
root.removeEventListener('mousedown', activatePasteTarget, { capture: true })
root.removeEventListener('keydown', handleKeyDown)
}
}, [activatePlainTextPaste, handleAutocompleteKey])
useRawEditorWikilinkInsertion({
debounceRef: pendingChanges.debounceRef,
@@ -455,12 +472,9 @@ export function RawEditorView({ content, path, entries, sourceEntry, onContentCh
return (
<div
ref={rootRef}
className="flex flex-1 flex-col min-h-0 relative"
style={{ background: 'var(--background)' }}
onFocusCapture={activatePlainTextPaste}
onKeyDown={handleAutocompleteKey}
onMouseDownCapture={activatePlainTextPaste}
role="presentation"
>
<RawEditorYamlErrorBanner error={pendingChanges.yamlError} />
<RawEditorFindBar
@@ -478,7 +492,7 @@ export function RawEditorView({ content, path, entries, sourceEntry, onContentCh
ref={containerRef}
className="raw-editor-codemirror flex flex-1 min-h-0"
data-testid="raw-editor-codemirror"
aria-label={translate(locale, 'editor.raw.label')}
role="presentation"
/>
<RawEditorAutocompleteDropdown
autocomplete={autocomplete}

View File

@@ -21,13 +21,13 @@ export function RenameDetectedBanner({ renames, onUpdate, onDismiss }: RenameDet
<span className="flex-1 text-foreground">
{count} file{count !== 1 ? 's' : ''} renamed outside Tolaria. Update wikilinks?
</span>
<button
<button type="button"
className="shrink-0 cursor-pointer rounded-md bg-primary px-3 py-1 text-[12px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
onClick={onUpdate}
>
Update wikilinks
</button>
<button
<button type="button"
className="shrink-0 cursor-pointer rounded-md border border-border bg-transparent px-3 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted"
onClick={onDismiss}
>

View File

@@ -5,13 +5,14 @@ interface ResizeHandleProps {
}
export function ResizeHandle({ onResize }: ResizeHandleProps) {
const handleRef = useRef<HTMLDivElement>(null)
const isDragging = useRef(false)
const lastX = useRef(0)
const pendingDelta = useRef(0)
const rafId = useRef(0)
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
(e: MouseEvent) => {
e.preventDefault()
isDragging.current = true
lastX.current = e.clientX
@@ -65,10 +66,17 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
}
}, [onResize])
useEffect(() => {
const handle = handleRef.current
if (!handle) return
handle.addEventListener('mousedown', handleMouseDown)
return () => handle.removeEventListener('mousedown', handleMouseDown)
}, [handleMouseDown])
return (
<div
ref={handleRef}
className="relative z-30 -ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
onMouseDown={handleMouseDown}
/>
)
}

View File

@@ -184,6 +184,7 @@ export function SearchPanel({
onClose,
}: SearchPanelProps) {
const dateDisplayFormat = useDateDisplayFormat()
const rootRef = useRef<HTMLDivElement>(null)
const {
elapsedMs,
entryLookup,
@@ -201,16 +202,34 @@ export function SearchPanel({
typeEntryMap,
} = useSearchPanelController({ open, vaultPath, entries, onSelectNote, onClose })
useEffect(() => {
if (!open) return
const root = rootRef.current
if (!root) return
const handleRootClick = (event: MouseEvent) => {
if (event.target === root) onClose()
}
root.addEventListener('click', handleRootClick)
return () => root.removeEventListener('click', handleRootClick)
}, [open, onClose])
if (!open) return null
return (
<div
ref={rootRef}
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
onClick={onClose}
>
<button
type="button"
aria-label="Close search"
className="absolute inset-0 z-0 cursor-default border-0 bg-transparent p-0"
onClick={onClose}
/>
<div
className="flex w-[540px] max-w-[90vw] max-h-[480px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
onClick={e => e.stopPropagation()}
className="relative z-10 flex w-[540px] max-w-[90vw] max-h-[480px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
>
<SearchInput
ref={inputRef}
@@ -249,7 +268,7 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
function SearchInput({ query, loading, onChange, onKeyDown }, ref) {
return (
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<svg className="h-4 w-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg aria-hidden="true" className="h-4 w-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
@@ -264,6 +283,7 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
/>
{loading && (
<svg
aria-hidden="true"
className="h-4 w-4 shrink-0 animate-spin text-muted-foreground"
data-testid="search-spinner"
viewBox="0 0 24 24"
@@ -361,9 +381,10 @@ function SearchResultRow({
})
return (
<div
<button
type="button"
className={cn(
"cursor-pointer px-4 py-2.5 transition-colors",
"w-full cursor-pointer border-0 bg-transparent px-4 py-2.5 text-left transition-colors",
selected ? "bg-accent" : "hover:bg-secondary",
)}
onClick={() => onSelect(result)}
@@ -381,7 +402,7 @@ function SearchResultRow({
<WorkspaceInitialsBadge workspace={presentation.workspace} testId="search-result-workspace-badge" />
</div>
<SearchResultSubtitle subtitle={presentation.subtitle} />
</div>
</button>
)
}

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import { useId, type ReactNode } from 'react'
import { Input } from './ui/input'
import {
Select,
@@ -107,6 +107,7 @@ export function SettingsRow({
}
export function SelectControl({
id,
value,
onValueChange,
options,
@@ -114,6 +115,7 @@ export function SelectControl({
ariaLabel,
autoFocus = false,
}: {
id?: string
value: string
onValueChange: (value: string) => void
options: SelectOption[]
@@ -124,6 +126,7 @@ export function SelectControl({
return (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger
id={id}
className="w-full bg-transparent"
aria-label={ariaLabel}
data-testid={testId}
@@ -173,17 +176,19 @@ export function NumberInputControl({
}
export function SettingsSwitchControl({
id,
label,
checked,
onChange,
disabled = false,
}: {
id?: string
label: string
checked: boolean
onChange: (value: boolean) => void
disabled?: boolean
}) {
return <Switch checked={checked} onCheckedChange={onChange} aria-label={label} disabled={disabled} />
return <Switch id={id} checked={checked} onCheckedChange={onChange} aria-label={label} disabled={disabled} />
}
export function LabeledSelect({
@@ -201,10 +206,13 @@ export function LabeledSelect({
testId: string
autoFocus?: boolean
}) {
const triggerId = useId()
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>{label}</label>
<label htmlFor={triggerId} style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>{label}</label>
<SelectControl
id={triggerId}
value={value}
onValueChange={onValueChange}
options={options}
@@ -258,8 +266,12 @@ export function SettingsSwitchRow({
disabled?: boolean
testId?: string
}) {
const generatedId = useId()
const switchId = testId ?? generatedId
return (
<label
htmlFor={switchId}
className={`${SETTINGS_GROUP_ITEM_CLASS} flex flex-col gap-3 lg:flex-row lg:items-center`}
style={{ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.6 : 1 }}
data-testid={testId}
@@ -269,7 +281,7 @@ export function SettingsSwitchRow({
<div className="text-xs leading-5 text-muted-foreground">{description}</div>
</div>
<div className="flex justify-start lg:shrink-0 lg:justify-end">
<SettingsSwitchControl label={label} checked={checked} onChange={onChange} disabled={disabled} />
<SettingsSwitchControl id={switchId} label={label} checked={checked} onChange={onChange} disabled={disabled} />
</div>
</label>
)

View File

@@ -20,8 +20,6 @@ import {
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from 'react'
import type { Settings } from '../types'
@@ -191,7 +189,7 @@ const DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS = 90
const DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS = 30
type Translate = ReturnType<typeof createTranslator>
function isSaveShortcut(event: ReactKeyboardEvent): boolean {
function isSaveShortcut(event: { ctrlKey: boolean; key: string; metaKey: boolean }): boolean {
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
}
@@ -355,6 +353,7 @@ function SettingsPanelInner({
onClose,
}: SettingsPanelInnerProps) {
const [draft, setDraft] = useState(() => createSettingsDraft(settings, explicitOrganizationEnabled))
const backdropRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const draftLocale = resolveEffectiveLocale(draft.uiLanguage, [systemLocale])
const t = createTranslator(draftLocale)
@@ -406,12 +405,8 @@ function SettingsPanelInner({
onClose()
}, [draft, onClose, onSave, onSaveExplicitOrganization, settings])
const handleBackdropClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) onClose()
}, [onClose])
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent) => {
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation()
onClose()
@@ -422,21 +417,35 @@ function SettingsPanelInner({
event.preventDefault()
handleSave()
}
},
[handleSave, onClose],
)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSave, onClose])
useEffect(() => {
const backdrop = backdropRef.current
if (!backdrop) return
const handleBackdropClick = (event: MouseEvent) => {
if (event.target === backdrop) onClose()
}
backdrop.addEventListener('click', handleBackdropClick)
return () => backdrop.removeEventListener('click', handleBackdropClick)
}, [onClose])
return (
<div
ref={backdropRef}
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'var(--shadow-overlay)' }}
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
data-testid="settings-panel"
>
<SettingsBackdropCloseButton onClose={onClose} t={t} />
<div
ref={panelRef}
className="rounded-lg border border-border bg-background shadow-[0_18px_55px_var(--shadow-dialog)]"
className="relative rounded-lg border border-border bg-background shadow-[0_18px_55px_var(--shadow-dialog)]"
style={{ width: 'min(960px, calc(100vw - 48px))', maxHeight: '86vh', display: 'flex', flexDirection: 'column' }}
>
<SettingsHeader onClose={onClose} t={t} />
@@ -462,6 +471,17 @@ function SettingsPanelInner({
)
}
function SettingsBackdropCloseButton({ onClose, t }: { onClose: () => void; t: Translate }) {
return (
<button
type="button"
aria-label={t('settings.close')}
className="absolute inset-0 cursor-default border-0 bg-transparent p-0"
onClick={onClose}
/>
)
}
function SettingsHeader({ onClose, t }: { onClose: () => void; t: Translate }) {
return (
<div

View File

@@ -1,4 +1,4 @@
import { type ComponentType } from 'react'
import { useEffect, useRef, type ComponentType, type MouseEvent as ReactMouseEvent } from 'react'
import type { SidebarSelection, VaultEntry, WorkspaceIdentity } from '../types'
import { cn } from '@/lib/utils'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
@@ -141,7 +141,7 @@ export function SidebarLoadingCountPill({ compact, testId = 'sidebar-count-skele
}
function NavItemLabel({ label, compact }: { label: string; compact?: boolean }) {
return <span className={cn("flex-1 font-medium", getNavItemTextClass(compact))}>{label}</span>
return <span className={cn("min-w-0 flex-1 truncate text-left font-medium", getNavItemTextClass(compact))}>{label}</span>
}
function NavItemCount({
@@ -224,8 +224,11 @@ function ClickableNavItem({
padding: ReturnType<typeof getNavItemPadding>
}) {
return (
<div
className={cn("flex cursor-pointer select-none items-center gap-2 rounded transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")}
<Button
type="button"
variant="ghost"
size="sm"
className={cn("h-auto w-full cursor-pointer select-none justify-start rounded text-left transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")}
style={{ padding, borderRadius: 4 }}
onClick={onClick}
>
@@ -238,7 +241,7 @@ function ClickableNavItem({
style={resolveBadgeStyle(isActive, activeBadgeClassName, activeBadgeStyle, badgeStyle)}
compact={compact}
/>
</div>
</Button>
)
}
@@ -455,8 +458,9 @@ function SectionHeaderLabel({
}
return (
<span
className="min-w-0 truncate text-[13px] font-medium"
<button
type="button"
className="min-w-0 truncate border-0 bg-transparent p-0 text-left text-[13px] font-medium"
style={{ marginLeft: 4, color: getSectionHeaderTitleColor(isActive, sectionColor) }}
onDoubleClick={(event) => {
event.stopPropagation()
@@ -464,7 +468,7 @@ function SectionHeaderLabel({
}}
>
{label}
</span>
</button>
)
}
@@ -497,14 +501,35 @@ function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, ite
onStartRename?: () => void; onSelectTypeNote?: () => void
locale?: AppLocale
}) {
const headerRef = useRef<HTMLDivElement>(null)
const selectHandler = getSectionSelectHandler(isRenaming, onSelect)
const contextMenuHandler = getSectionContextMenuHandler(isRenaming, onContextMenu)
const doubleClickHandler = !isRenaming ? onSelectTypeNote : undefined
useEffect(() => {
const header = headerRef.current
if (!header) return
const handleClick = () => selectHandler?.()
const handleContextMenu = (event: MouseEvent) => contextMenuHandler?.(event as unknown as ReactMouseEvent)
const handleDoubleClick = () => doubleClickHandler?.()
header.addEventListener('click', handleClick)
header.addEventListener('contextmenu', handleContextMenu)
header.addEventListener('dblclick', handleDoubleClick)
return () => {
header.removeEventListener('click', handleClick)
header.removeEventListener('contextmenu', handleContextMenu)
header.removeEventListener('dblclick', handleDoubleClick)
}
}, [contextMenuHandler, doubleClickHandler, selectHandler])
return (
<div
ref={headerRef}
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", !isActive && "hover:bg-accent")}
style={{ padding: SIDEBAR_ITEM_PADDING.withCount, borderRadius: 4, gap: 4, ...getSectionHeaderBackground(isActive, sectionLightColor) }}
{...dragHandleProps}
onClick={getSectionSelectHandler(isRenaming, onSelect)}
onContextMenu={getSectionContextMenuHandler(isRenaming, onContextMenu)}
onDoubleClick={!isRenaming ? onSelectTypeNote : undefined}
>
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
<Icon size={16} weight={getSectionHeaderIconWeight(isActive)} style={{ color: sectionColor, flexShrink: 0 }} />

View File

@@ -375,7 +375,7 @@ function emojiSuggestionRank(entry: EmojiEntry, query: string): number {
const normalizedName = entry.name.toLowerCase()
const tokens = normalizedName.split(/[^a-z0-9]+/).filter(Boolean)
if (normalizedName === query) return 0
if (tokens.some(token => token === query)) return 1
if (tokens.includes(query)) return 1
if (tokens.some(token => token.startsWith(query))) return 2
if (normalizedName.startsWith(query)) return 3
return 4
@@ -1379,6 +1379,17 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
activatePlainTextPaste()
handleWhitespaceMouseSelection(event)
}, [activatePlainTextPaste, handleWhitespaceMouseSelection])
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleClick = (event: MouseEvent) => {
handleContainerClick(event as unknown as React.MouseEvent<HTMLDivElement>)
}
container.addEventListener('click', handleClick)
return () => container.removeEventListener('click', handleClick)
}, [handleContainerClick])
const insertWikilink = useInsertWikilink(editor, runEditorAction)
const suggestionMenuItems = useSuggestionMenuItems({
baseItems,
@@ -1394,9 +1405,10 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
return (
<div
ref={containerRef}
role="application"
aria-label="Rich text editor"
className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`}
style={cssVars as React.CSSProperties}
onClick={handleContainerClick}
onCopyCapture={handleEditorCopy}
onFocusCapture={handleFocusCapture}
onMouseLeave={clearCopyTarget}

View File

@@ -41,7 +41,7 @@ function ColorPickerRow({ status, onColorChange }: { status: string; onColorChan
return (
<div className="flex items-center gap-1 px-3 py-1.5" data-testid={`color-picker-${status}`}>
{ACCENT_COLORS.map(c => (
<button
<button type="button"
key={c.key}
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0 transition-transform hover:scale-125"
style={{ backgroundColor: c.css }}
@@ -84,19 +84,20 @@ function StatusOption({
borderRadius: 4,
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
}}
onMouseEnter={onMouseEnter}
>
<button
<button type="button"
className="flex min-w-0 flex-1 items-center border-none bg-transparent p-0 text-left"
onClick={() => onSelect(status)}
onMouseEnter={onMouseEnter}
data-testid={`status-option-${status}`}
>
<StatusPill status={status} />
</button>
<button
<button type="button"
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0"
style={{ backgroundColor: style.color }}
onClick={() => onToggleColor(status)}
onMouseEnter={onMouseEnter}
title="Change color"
data-testid={`status-color-swatch-${status}`}
/>
@@ -302,7 +303,13 @@ export function StatusDropdown({
<span ref={anchorRef} data-testid="status-dropdown">
{createPortal(
<>
<div className="fixed inset-0 z-[12000]" onClick={onCancel} data-testid="status-dropdown-backdrop" />
<button
type="button"
aria-label="Close status menu"
className="fixed inset-0 z-[12000] cursor-default border-0 bg-transparent p-0"
onClick={onCancel}
data-testid="status-dropdown-backdrop"
/>
<div
ref={dropdownRef}
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
@@ -379,7 +386,7 @@ function CreateSection({ show, query, showDivider, highlighted, onSave, onMouseE
return (
<>
{showDivider && <div className="my-1 h-px bg-border" />}
<button
<button type="button"
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
style={{
borderRadius: 4,

View File

@@ -67,11 +67,11 @@ function TagOption({
<div
className="flex w-full items-center gap-1 px-2 py-1 transition-colors"
style={{ borderRadius: 4, backgroundColor: highlighted ? 'var(--muted)' : 'transparent' }}
onMouseEnter={onMouseEnter}
>
<button
<button type="button"
className="flex min-w-0 flex-1 items-center gap-1.5 border-none bg-transparent p-0 text-left"
onClick={() => onToggle(tag)}
onMouseEnter={onMouseEnter}
data-testid={`tag-option-${tag}`}
>
<span className="w-3.5 text-center text-[10px]" style={{ color: style.color }}>
@@ -79,10 +79,11 @@ function TagOption({
</span>
<TagPill tag={tag} />
</button>
<button
<button type="button"
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0"
style={{ backgroundColor: style.color }}
onClick={() => onToggleColor(tag)}
onMouseEnter={onMouseEnter}
title="Change color"
data-testid={`tag-color-swatch-${tag}`}
/>
@@ -241,7 +242,13 @@ export function TagsDropdown({
<span ref={anchorRef} data-testid="tags-dropdown">
{createPortal(
<>
<div className="fixed inset-0 z-[12000]" onClick={onClose} data-testid="tags-dropdown-backdrop" />
<button
type="button"
aria-label="Close tags menu"
className="fixed inset-0 z-[12000] cursor-default border-0 bg-transparent p-0"
onClick={onClose}
data-testid="tags-dropdown-backdrop"
/>
<div
ref={dropdownRef}
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
@@ -338,7 +345,7 @@ function CreateTagSection({ show, query, showDivider, highlighted, onToggle, onM
return (
<>
{showDivider && <div className="my-1 h-px bg-border" />}
<button
<button type="button"
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
style={{
borderRadius: 4,

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type MutableRefObject, type PointerEvent as ReactPointerEvent } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MutableRefObject, type PointerEvent as ReactPointerEvent } from 'react'
import { getAssetUrlsByImport } from '@tldraw/assets/imports.vite'
import { Dialog as DialogPrimitive } from 'radix-ui'
import {
@@ -197,8 +197,12 @@ function installZoomAwareViewport(editor: Editor): () => void {
return () => {
window.removeEventListener('laputa-zoom-change', scheduleViewportUpdate)
animationFrameIds.forEach((id) => window.cancelAnimationFrame(id))
timeoutIds.forEach((id) => window.clearTimeout(id))
animationFrameIds.forEach((id) => {
window.cancelAnimationFrame(id)
})
timeoutIds.forEach((id) => {
window.clearTimeout(id)
})
editor.updateViewportScreenBounds = updateViewportScreenBounds
editor.dispatch = dispatch
}
@@ -241,12 +245,12 @@ function canDismissDialog(openedAt: number): boolean {
return performance.now() - openedAt >= DIALOG_OPEN_DISMISS_GRACE_MS
}
function isOverlayEvent(event: ReactMouseEvent<HTMLDivElement>): boolean {
function isOverlayEvent(event: { currentTarget: EventTarget | null; target: EventTarget | null }): boolean {
return event.target === event.currentTarget
}
function shouldCloseFromOverlayClick(
event: ReactMouseEvent<HTMLDivElement>,
event: { currentTarget: EventTarget | null; target: EventTarget | null },
dialog: TLUiDialog,
mouseDownInsideContent: boolean
): boolean {
@@ -291,6 +295,7 @@ function TolariaTldrawDialogContent({
}
const TolariaTldrawDialog = memo(function TolariaTldrawDialog({ dialog, onClose }: TolariaTldrawDialogProps) {
const overlayRef = useRef<HTMLDivElement>(null)
const mouseDownInsideContentRef = useRef(false)
const { openedAtRef, readyToOpen } = useDeferredDialogOpen()
@@ -302,22 +307,35 @@ const TolariaTldrawDialog = memo(function TolariaTldrawDialog({ dialog, onClose
const handleOpenChange = useCallback((isOpen: boolean) => {
if (!isOpen) closeDialogNow()
}, [closeDialogNow])
useEffect(() => {
const overlay = overlayRef.current
if (!overlay) return
const handleMouseDown = (event: MouseEvent) => {
if (event.target === overlay) mouseDownInsideContentRef.current = false
}
const handleClick = (event: MouseEvent) => {
if (shouldCloseFromOverlayClick({ currentTarget: overlay, target: event.target }, dialog, mouseDownInsideContentRef.current)) {
closeDialogFromBackground()
}
}
overlay.addEventListener('mousedown', handleMouseDown)
overlay.addEventListener('click', handleClick)
return () => {
overlay.removeEventListener('mousedown', handleMouseDown)
overlay.removeEventListener('click', handleClick)
}
}, [closeDialogFromBackground, dialog])
if (!readyToOpen) return null
return (
<DialogPrimitive.Root open onOpenChange={handleOpenChange}>
<div
ref={overlayRef}
dir="ltr"
className="tlui-dialog__overlay"
onMouseDown={(event) => {
if (event.target === event.currentTarget) mouseDownInsideContentRef.current = false
}}
onClick={(event) => {
if (shouldCloseFromOverlayClick(event, dialog, mouseDownInsideContentRef.current)) {
closeDialogFromBackground()
}
}}
>
<TolariaTldrawDialogContent
dialog={dialog}
@@ -436,7 +454,7 @@ export function TldrawWhiteboard({
}
}, [boardId, store])
const startResize = (mode: ResizeMode) => (event: ReactPointerEvent<HTMLDivElement>) => {
const startResize = (mode: ResizeMode) => (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
@@ -489,23 +507,23 @@ export function TldrawWhiteboard({
store={store}
user={tldrawUser}
/>
<div
<button
type="button"
aria-label="Resize whiteboard width"
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--width"
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--width border-0 bg-transparent p-0"
onPointerDown={startResize('width')}
role="separator"
/>
<div
<button
type="button"
aria-label="Resize whiteboard height"
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--height"
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--height border-0 bg-transparent p-0"
onPointerDown={startResize('height')}
role="separator"
/>
<div
<button
type="button"
aria-label="Resize whiteboard"
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--corner"
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--corner border-0 bg-transparent p-0"
onPointerDown={startResize('both')}
role="separator"
/>
</div>
)

View File

@@ -236,8 +236,6 @@ export function TypeCustomizePopover({
surface === 'popover' && 'rounded-lg border bg-popover shadow-md',
)}
style={surface === 'popover' ? { width: 320, padding: 12 } : undefined}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.stopPropagation()}
>
<ColorSection selectedColor={selectedColor} locale={locale} onSelectColor={handleColorClick} />
<IconSection

View File

@@ -221,7 +221,7 @@ function ReadOnlyType({
<TypeRowLabel />
<TypeRowValue missingTypeName={missingTypeName} locale={locale} onCreateMissingType={onCreateMissingType}>
{onNavigate ? (
<button
<button type="button"
className="min-w-0 max-w-full truncate border-none cursor-pointer ring-inset hover:ring-1 hover:ring-current"
style={{
...PROPERTY_CHIP_STYLE,

View File

@@ -327,6 +327,7 @@ function useWelcomeActionButtons({
)
useEffect(() => {
void mode
if (busy) return
// WKWebView can ignore `autoFocus`; move focus explicitly so keyboard-only

View File

@@ -359,7 +359,7 @@ describe('WikilinkChatInput', () => {
render(
<>
<Controlled onDraftChange={onDraftChange} />
<button data-testid="other-target">Other</button>
<button type="button" data-testid="other-target">Other</button>
</>,
)
const initialEditor = screen.getByTestId('agent-input') as HTMLDivElement

View File

@@ -2,13 +2,12 @@ import { cn } from '@/lib/utils'
import type { WorkspaceIdentity } from '../types'
interface WorkspaceInitialsBadgeProps {
ariaLabel?: string
className?: string
testId?: string
workspace?: WorkspaceIdentity | null
}
export function WorkspaceInitialsBadge({ ariaLabel, className, testId, workspace }: WorkspaceInitialsBadgeProps) {
export function WorkspaceInitialsBadge({ className, testId, workspace }: WorkspaceInitialsBadgeProps) {
if (!workspace) return null
const accentColor = workspace.color ? `var(--accent-${workspace.color})` : 'var(--muted-foreground)'
@@ -21,7 +20,6 @@ export function WorkspaceInitialsBadge({ ariaLabel, className, testId, workspace
)}
style={{ borderColor: accentColor, color: accentColor }}
title={`${workspace.label} (${workspace.alias})`}
aria-label={ariaLabel ?? `Workspace ${workspace.label}`}
data-testid={testId}
>
<span className="block h-[16px] leading-[16px]">{workspace.shortLabel}</span>

View File

@@ -103,7 +103,7 @@ function DiffModeView({ diffContent, locale = 'en', onToggleDiff }: { diffConten
return (
<div className="flex-1 overflow-auto">
<button
<button type="button"
className="flex items-center gap-1.5 px-4 py-2 text-xs text-primary bg-muted border-b border-border cursor-pointer hover:bg-accent transition-colors w-full border-t-0 border-l-0 border-r-0"
onClick={onToggleDiff}
title={label}

View File

@@ -1,4 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import type { MouseEvent as ReactMouseEvent, MouseEventHandler } from 'react'
import {
Folder,
FolderOpen,
@@ -14,7 +14,7 @@ interface FolderItemRowProps {
isExpanded: boolean
isSelected: boolean
node: FolderNode
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLElement>) => void
onSelect: () => void
onStartRenameFolder?: (folderPath: string) => void
onToggle: () => void
@@ -50,14 +50,6 @@ export function FolderItemRow({
: 'text-foreground hover:bg-accent',
)}
style={{ paddingLeft: depthIndent, borderRadius: 4 }}
onContextMenu={(event) => {
if (!canOpenMenu) {
event.preventDefault()
return
}
onSelect()
onOpenMenu(node, event)
}}
>
<FolderSelectButton
contentInset={contentInset}
@@ -66,6 +58,14 @@ export function FolderItemRow({
isSelected={isSelected}
node={node}
onClick={handleSelectClick}
onContextMenu={(event) => {
if (!canOpenMenu) {
event.preventDefault()
return
}
onSelect()
onOpenMenu(node, event)
}}
onDoubleClick={handleRenameDoubleClick}
/>
</div>
@@ -79,6 +79,7 @@ function FolderSelectButton({
isSelected,
node,
onClick,
onContextMenu,
onDoubleClick,
}: {
contentInset: number
@@ -87,6 +88,7 @@ function FolderSelectButton({
isSelected: boolean
node: FolderNode
onClick: (clickDetail: number) => void
onContextMenu: MouseEventHandler<HTMLButtonElement>
onDoubleClick: () => void
}) {
return (
@@ -106,6 +108,7 @@ function FolderSelectButton({
title={node.path || node.name}
aria-expanded={hasChildren ? isExpanded : undefined}
onClick={(event) => onClick(event.detail)}
onContextMenu={onContextMenu}
onDoubleClick={onDoubleClick}
data-testid={`folder-row:${node.path}`}
>

View File

@@ -11,7 +11,7 @@ interface FolderTreeRowProps {
expanded: Record<string, boolean>
node: FolderNode
onDeleteFolder?: (folderPath: string) => void
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLElement>) => void
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
onSelect: (selection: SidebarSelection) => void
onStartRenameFolder?: (folderPath: string) => void

View File

@@ -21,7 +21,7 @@ export function useFolderContextMenu({
openContextMenuFromPointer,
} = useSidebarContextMenu<string>()
const handleOpenMenu = useCallback((node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => {
const handleOpenMenu = useCallback((node: FolderNode, event: ReactMouseEvent<HTMLElement>) => {
openContextMenuFromPointer(node.path, event)
}, [openContextMenuFromPointer])

View File

@@ -16,7 +16,7 @@ function BacklinkEntry({ entry, context, onNavigate }: {
}) {
const isDimmed = entry.archived
return (
<button
<button type="button"
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:underline"
onClick={() => onNavigate(entry.title)}
title={entryStatusTitle(entry)}

View File

@@ -23,7 +23,7 @@ export function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCom
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
<button
<button type="button"
className="mb-0.5 w-full cursor-pointer truncate border-none bg-transparent p-0 text-left text-xs text-primary hover:underline"
onClick={() => onViewCommitDiff?.(c.hash)}
title={`View diff for ${c.shortHash}`}

View File

@@ -32,7 +32,7 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
onToggle: () => void
onOpenRawEditor?: () => void
}) {
const { onMouseDown } = useDragRegion()
const { dragRegionRef } = useDragRegion<HTMLDivElement>()
const propertiesTitle = translate(locale, 'inspector.title.properties')
const showWarnings = Boolean(frontmatterWarnings && hasFrontmatterWarnings(frontmatterWarnings) && onOpenRawEditor)
const propertiesIcon = (testId?: string) => (
@@ -48,12 +48,12 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
return (
<div
ref={dragRegionRef}
className="flex shrink-0 items-center border-b border-border"
style={{ height: 52, padding: '6px 12px', gap: 8, cursor: 'default' }}
onMouseDown={onMouseDown}
>
{collapsed ? (
<button
<button type="button"
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
title={toggleLabel}
@@ -63,7 +63,7 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
</button>
) : (
<>
<button
<button type="button"
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
title={toggleLabel}
@@ -76,7 +76,7 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
<FrontmatterWarningsButton locale={locale} onOpenRawEditor={onOpenRawEditor} />
)}
<span className="flex-1" />
<button
<button type="button"
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
title={toggleLabel}
@@ -99,7 +99,7 @@ export function InitializePropertiesPrompt({ locale = 'en', onClick }: { locale?
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border px-4 py-6">
<Sparkle size={24} className="text-muted-foreground" />
<p className="m-0 text-center text-[13px] text-muted-foreground">{translate(locale, 'inspector.empty.noProperties')}</p>
<button
<button type="button"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={onClick}
>
@@ -114,7 +114,7 @@ export function InvalidFrontmatterNotice({ locale = 'en', onFix }: { locale?: Ap
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-6">
<WarningCircle size={24} className="text-destructive" />
<p className="m-0 text-center text-[13px] text-muted-foreground">{translate(locale, 'inspector.empty.invalidProperties')}</p>
<button
<button type="button"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={onFix}
>

View File

@@ -21,35 +21,38 @@ export function LinkButton({ label, noteIcon, typeColor, bgColor, isArchived, on
const isDimmed = isArchived
const color = isDimmed ? 'var(--muted-foreground)' : typeColor
return (
<button
className={`group/link flex w-full items-center justify-between gap-2 border-none text-left cursor-pointer min-w-0${bgColor ? ' ring-inset hover:ring-1 hover:ring-current' : ' hover:opacity-80'}`}
<span
className={`group/link flex w-full min-w-0 items-center justify-between gap-2${bgColor ? ' ring-inset hover:ring-1 hover:ring-current' : ' hover:opacity-80'}`}
style={{
background: isDimmed ? 'var(--muted)' : (bgColor ?? 'transparent'),
color, borderRadius: 6, padding: bgColor ? '6px 10px' : '4px 0',
fontSize: 12, fontWeight: 500, opacity: isDimmed ? 0.7 : 1,
}}
>
<button
type="button"
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 truncate border-0 bg-transparent p-0 text-left text-[inherit]"
onClick={onClick}
title={title}
>
<span className="flex items-center gap-1 flex-1 truncate">
<NoteTitleIcon icon={noteIcon} size={14} />
{label}
<StatusSuffix isArchived={isArchived} />
</span>
</button>
<span className="flex items-center gap-1.5 shrink-0">
{onRemove && (
<span
className="flex items-center opacity-0 transition-opacity group-hover/link:opacity-100"
onClick={(e) => { e.stopPropagation(); onRemove() }}
role="button"
<button
type="button"
className="flex items-center border-0 bg-transparent p-0 text-[inherit] opacity-0 transition-opacity group-hover/link:opacity-100"
onClick={onRemove}
title="Remove from relation"
data-testid="remove-relation-ref"
>
<X size={14} />
</span>
</button>
)}
<TypeIcon width={14} height={14} className="shrink-0" style={{ color, opacity: 0.5 }} />
</span>
</button>
</span>
)
}

View File

@@ -55,7 +55,7 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
return (
<div key={label} className="flex min-w-0 flex-col gap-1 px-1.5">
<ActionTooltip copy={{ label: tooltip }} side="top" align="start">
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME} tabIndex={0} data-testid="derived-relationship-label">
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME} data-testid="derived-relationship-label">
{label}
</span>
</ActionTooltip>

View File

@@ -1,4 +1,4 @@
import { useMemo, useCallback, useState, useRef, type KeyboardEvent, type ReactNode } from 'react'
import { useMemo, useCallback, useState, useRef, useEffect, type KeyboardEvent, type ReactNode } from 'react'
import type { VaultEntry } from '../../types'
import { Plus, X } from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../../utils/frontmatter'
@@ -234,8 +234,9 @@ function CreateAndOpenOption({ title, selected, locale, onClick, onHover }: {
onHover: () => void
}) {
return (
<div
className={`flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm transition-colors ${selected ? 'bg-accent' : 'hover:bg-secondary'}`}
<button
type="button"
className={`flex w-full cursor-pointer items-center gap-2 border-0 bg-transparent px-3 py-1.5 text-left text-sm transition-colors ${selected ? 'bg-accent' : 'hover:bg-secondary'}`}
data-testid="create-and-open-option"
onMouseDown={e => e.preventDefault()}
onClick={onClick}
@@ -245,7 +246,7 @@ function CreateAndOpenOption({ title, selected, locale, onClick, onHover }: {
<span className="truncate text-foreground">
{translate(locale, 'inspector.relationship.createAndOpen')} <strong>{title}</strong>
</span>
</div>
</button>
)
}
@@ -398,9 +399,13 @@ function InlineAddNote({ entries, sourceEntry, vaultPath, locale, onAdd, onCreat
handleCreateAndOpen,
} = useInlineAddNoteState(entries, vaultPath, sourceEntry, onAdd, onCreateAndOpenNote)
useEffect(() => {
if (active) inputRef.current?.focus()
}, [active, inputRef])
if (!active) {
return (
<button
<button type="button"
className="mt-1 w-full border border-dashed border-border bg-transparent text-left text-muted-foreground cursor-pointer hover:border-foreground hover:text-foreground"
style={{ borderRadius: 6, padding: '6px 10px', fontSize: 12 }}
onClick={() => setActive(true)}
@@ -416,7 +421,6 @@ function InlineAddNote({ entries, sourceEntry, vaultPath, locale, onAdd, onCreat
<div className="group/add relative flex items-center">
<input
ref={inputRef}
autoFocus
className="w-full border border-border bg-transparent text-foreground"
style={{ borderRadius: 6, outline: 'none', minWidth: 0, padding: '6px 10px', fontSize: 12 }}
placeholder={translate(locale, 'inspector.relationship.noteTitle')}
@@ -425,7 +429,7 @@ function InlineAddNote({ entries, sourceEntry, vaultPath, locale, onAdd, onCreat
onKeyDown={handleKeyDown}
data-testid="add-relation-ref-input"
/>
<button
<button type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 border-none bg-transparent p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/add:opacity-100"
onClick={dismiss}
>
@@ -459,11 +463,11 @@ function RelationshipGroup({ label, refs, entries, sourceEntry, typeEntryMap, va
return (
<RelationshipSectionRow label={label} locale={locale}>
<div className="flex flex-col gap-1">
{refs.map((ref, idx) => {
{refs.map((ref) => {
const props = resolveRefProps(ref, entries, typeEntryMap)
return (
<LinkButton
key={`${ref}-${idx}`}
key={`${ref}-${props.target}`}
{...props}
onClick={() => onNavigate(props.target)}
onRemove={onRemoveRef ? () => onRemoveRef(ref) : undefined}
@@ -879,7 +883,7 @@ function updateRefsForAddition(refs: string[], refToAdd: string): FrontmatterVal
function DisabledLinkButton({ locale }: { locale: AppLocale }) {
return (
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>{translate(locale, 'inspector.relationship.addRelationship')}</button>
<button type="button" className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>{translate(locale, 'inspector.relationship.addRelationship')}</button>
)
}

View File

@@ -1,6 +1,5 @@
import { createElement, useMemo, useState, type ComponentType, type MouseEvent, type ReactNode, type SVGAttributes } from 'react'
import { Link } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
import type { VaultEntry } from '../../types'
import { useDateDisplayFormat } from '../../hooks/useAppPreferences'
import { resolveNoteIcon } from '../../utils/noteIcon'
@@ -119,21 +118,43 @@ export function PropertyChips({
return (
<div className="mt-1 flex flex-wrap gap-1" data-testid="property-chips">
{chips.map(({ key, values }) =>
values.map((chip, index) => (
<span
key={`${key}-${index}`}
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground',
chip.action && 'cursor-pointer',
)}
style={chip.style}
onClick={(event) => { void handleChipClick(event, chip, onOpenNote) }}
data-testid={toChipTestId(key, index)}
>
values.map((chip, index) => {
const content = (
<>
<PropertyChipIcon noteIcon={chip.noteIcon} typeIcon={chip.typeIcon} tone={chip.tone} />
<span className="truncate whitespace-nowrap">{chip.label}</span>
</>
)
const chipKey = `${key}-${chip.tone}-${chip.label}`
const testId = toChipTestId(key, index)
if (!chip.action) {
return (
<span
key={chipKey}
className="inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
style={chip.style}
data-testid={testId}
data-property-chip="true"
>
{content}
</span>
))
)
}
return (
<button
type="button"
key={chipKey}
className="inline-flex max-w-full cursor-pointer items-center gap-1 rounded-md border-0 bg-muted px-1.5 py-0.5 text-left text-[10px] text-muted-foreground"
style={chip.style}
onClick={(event) => { void handleChipClick(event, chip, onOpenNote) }}
data-testid={testId}
data-property-chip="true"
>
{content}
</button>
)
})
)}
</div>
)

View File

@@ -100,10 +100,6 @@ function SortablePropertyItem({ id, checked, locale = 'en', onToggle }: { id: No
<label
htmlFor={inputId}
className="flex flex-1 cursor-pointer items-center gap-2 text-[13px]"
onClick={(event) => {
event.preventDefault()
onToggle(id)
}}
>
<span className="truncate">{id}</span>
</label>

View File

@@ -93,12 +93,22 @@ function HeaderTitle({
}: Pick<NoteListHeaderProps, 'title' | 'typeDocument' | 'onOpenType'>) {
const handleClick = typeDocument ? () => onOpenType(typeDocument) : undefined
if (typeDocument && handleClick) {
return (
<button
type="button"
className="m-0 min-w-0 flex-1 truncate border-0 bg-transparent p-0 text-left text-[14px] font-semibold"
onClick={handleClick}
data-testid="type-header-link"
>
{title}
</button>
)
}
return (
<h3
className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold"
style={typeDocument ? { cursor: 'pointer' } : undefined}
onClick={handleClick}
data-testid={typeDocument ? 'type-header-link' : undefined}
>
{title}
</h3>
@@ -291,14 +301,14 @@ export function NoteListHeader({
onSearchKeyDown,
onGitRepositoryChange,
}: NoteListHeaderProps) {
const { onMouseDown: onDragMouseDown } = useDragRegion()
const { dragRegionRef } = useDragRegion<HTMLDivElement>()
const collapsedSidebarPadding = sidebarCollapsed && isMac()
? COLLAPSED_SIDEBAR_MAC_CHROME_PADDING
: undefined
return (
<>
<div className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4" onMouseDown={onDragMouseDown} style={{ cursor: 'default', paddingLeft: collapsedSidebarPadding }}>
<div ref={dragRegionRef} className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4" style={{ cursor: 'default', paddingLeft: collapsedSidebarPadding }}>
<HeaderLeading
title={title}
typeDocument={typeDocument}

View File

@@ -9,10 +9,10 @@ type NoteListLayoutProps = ReturnType<typeof useNoteListModel> & {
}
const NOTE_LIST_LOADING_ROWS = [
{ title: 184, line: 254, selected: false },
{ title: 142, line: 220, selected: true },
{ title: 98, line: 242, selected: false },
{ title: 212, line: 198, selected: false },
{ id: 'wide', title: 184, line: 254, selected: false },
{ id: 'selected', title: 142, line: 220, selected: true },
{ id: 'short', title: 98, line: 242, selected: false },
{ id: 'long', title: 212, line: 198, selected: false },
]
function NoteListLoadingBar({ width }: { width: number }) {
@@ -52,8 +52,8 @@ function NoteListLoadingRow({
function NoteListLoadingSkeleton() {
return (
<div data-testid="note-list-loading-skeleton" className="animate-pulse">
{NOTE_LIST_LOADING_ROWS.map((row, index) => (
<NoteListLoadingRow key={index} {...row} />
{NOTE_LIST_LOADING_ROWS.map((row) => (
<NoteListLoadingRow key={row.id} {...row} />
))}
</div>
)
@@ -210,6 +210,8 @@ function NoteListBody({
ref={noteListContainerRef}
className="relative flex flex-1 flex-col overflow-hidden outline-none"
style={{ minHeight: 0 }}
role="listbox"
aria-label="Notes"
tabIndex={0}
onBlur={handleNoteListBlur}
onKeyDown={handleListKeyDown}

View File

@@ -19,7 +19,9 @@ function installAnimationFrameStub() {
flushAnimationFrame: () => {
const pending = [...callbacks.values()]
callbacks.clear()
pending.forEach((callback) => callback(0))
pending.forEach((callback) => {
callback(0)
})
},
}
}

View File

@@ -1207,8 +1207,10 @@ export function useNoteListInteractions({
})
useEffect(() => {
void noteListFilter
void selection
multiSelect.clear()
}, [noteListFilter, selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear only when selection/filter changes
}, [multiSelect.clear, noteListFilter, selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear only when selection/filter changes
const handleClickNote = useNoteClickHandler({
isChangesView,

View File

@@ -152,7 +152,7 @@ function releaseRecoveryState(
const state = Reflect.get(view, DISPATCH_RECOVERY_STATE_KEY)
if (!isDispatchRecoveryState(state) || state.originalDispatch !== originalDispatch) return
state.recoverDocuments = state.recoverDocuments.filter((entry) => entry.token !== token)
state.recoverDocuments = state.recoverDocuments.filter((entry) => !Object.is(entry.token, token))
state.refCount -= 1
if (state.refCount > 0) return

View File

@@ -62,18 +62,19 @@ function SortableFavoriteItem({
{...attributes}
{...listeners}
>
<div
<button
type="button"
className={`group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors ${isActive ? '' : 'hover:bg-accent'}`}
style={{ padding: SIDEBAR_ITEM_PADDING.withCount, borderRadius: 4, gap: 4, ...(isActive ? { background: typeLightColor } : {}) }}
onClick={onSelect}
>
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
<NoteTitleIcon icon={icon} size={16} color={typeColor} />
<span className="min-w-0 truncate text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? typeColor : undefined }}>
<span className="min-w-0 truncate border-0 bg-transparent p-0 text-left text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? typeColor : undefined }}>
{entry.title}
</span>
</div>
</div>
</button>
</div>
)
}

View File

@@ -19,6 +19,7 @@ interface SidebarLoadingActionProps {
}
interface SidebarLoadingRowProps {
id: string
icon?: ReactNode
iconColor?: string
labelWidth: number
@@ -50,27 +51,27 @@ interface CreatableLoadingSectionConfig {
}
const FAVORITE_ROWS = [
{ iconColor: 'var(--accent-yellow)', labelWidth: 132 },
{ iconColor: 'var(--accent-red)', labelWidth: 118 },
{ id: 'favorite-primary', iconColor: 'var(--accent-yellow)', labelWidth: 132 },
{ id: 'favorite-secondary', iconColor: 'var(--accent-red)', labelWidth: 118 },
]
const VIEW_ROWS = [
{ icon: <Funnel size={16} />, labelWidth: 118 },
{ icon: <Funnel size={16} />, labelWidth: 146, showCount: true },
{ id: 'view-filtered', icon: <Funnel size={16} />, labelWidth: 118 },
{ id: 'view-counted', icon: <Funnel size={16} />, labelWidth: 146, showCount: true },
]
const TYPE_ROWS = [
{ iconColor: 'var(--accent-red)', labelWidth: 72, showCount: true },
{ iconColor: 'var(--accent-orange)', labelWidth: 92, showCount: true },
{ iconColor: 'var(--accent-purple)', labelWidth: 104, showCount: true },
{ iconColor: 'var(--accent-blue)', labelWidth: 126, showCount: true },
{ iconColor: 'var(--accent-green)', labelWidth: 112, showCount: true },
{ iconColor: 'var(--accent-yellow)', labelWidth: 96, showCount: true },
{ id: 'type-red', iconColor: 'var(--accent-red)', labelWidth: 72, showCount: true },
{ id: 'type-orange', iconColor: 'var(--accent-orange)', labelWidth: 92, showCount: true },
{ id: 'type-purple', iconColor: 'var(--accent-purple)', labelWidth: 104, showCount: true },
{ id: 'type-blue', iconColor: 'var(--accent-blue)', labelWidth: 126, showCount: true },
{ id: 'type-green', iconColor: 'var(--accent-green)', labelWidth: 112, showCount: true },
{ id: 'type-yellow', iconColor: 'var(--accent-yellow)', labelWidth: 96, showCount: true },
]
const FOLDER_ROWS = [
{ icon: <Folder size={16} />, labelWidth: 118 },
{ icon: <Folder size={16} />, labelWidth: 92 },
{ id: 'folder-primary', icon: <Folder size={16} />, labelWidth: 118 },
{ id: 'folder-secondary', icon: <Folder size={16} />, labelWidth: 92 },
]
type CreatableLoadingSectionKind = 'views' | 'folders'
@@ -183,8 +184,8 @@ function SidebarLoadingSection({
</SidebarGroupHeader>
{!collapsed && (
<div className="flex flex-col gap-0.5 pb-2 animate-pulse" aria-hidden="true">
{rows.map((row, index) => (
<SidebarLoadingRow key={`${testId}-${index}`} {...row} />
{rows.map((row) => (
<SidebarLoadingRow key={`${testId}-${row.id}`} {...row} />
))}
</div>
)}

Some files were not shown because too many files have changed in this diff Show More