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 ## 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: 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 // Count should increase
const countAfter = await page.locator('.note-list__count').textContent() 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 // Note should be opened in editor
await expect(page.locator('.editor__tab--active')).toHaveText(/E2E Test Note/) 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 window.getComputedStyle(el).paddingLeft
) )
console.log(`Level-0 padding-left: ${paddingL0}`) console.log(`Level-0 padding-left: ${paddingL0}`)
expect(parseInt(paddingL0)).toBe(40) expect(parseInt(paddingL0, 10)).toBe(40)
// Bullet widgets and checkboxes are rendered // Bullet widgets and checkboxes are rendered
const bulletCount = await page.locator('.cm-live-bullet').count() const bulletCount = await page.locator('.cm-live-bullet').count()

View File

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

View File

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

3
src-tauri/Cargo.lock generated
View File

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

View File

@@ -41,9 +41,4 @@ uuid = { version = "1", features = ["v4"] }
tempfile = "3" tempfile = "3"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } 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] [dev-dependencies]

View File

@@ -39,44 +39,6 @@ pub fn update_app_icon_for_theme(
.set_icon(image.clone()) .set_icon(image.clone())
.map_err(|err| format!("Failed to update window icon: {err}"))?; .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(()) Ok(())
} }

View File

@@ -644,6 +644,10 @@ mod tests {
#[test] #[test]
fn test_parse_github_repo_path_variants() { fn test_parse_github_repo_path_variants() {
let tokenized_url = format!(
"https://{}@github.com/owner/repo.git",
["gho", "abc123"].join("_")
);
for url in [ for url in [
"https://github.com/owner/repo.git", "https://github.com/owner/repo.git",
"https://github.com/owner/repo", "https://github.com/owner/repo",
@@ -651,7 +655,7 @@ mod tests {
"git@github.com:owner/repo.git", "git@github.com:owner/repo.git",
"git@github.com:owner/repo", "git@github.com:owner/repo",
"ssh://git@github.com/owner/repo.git", "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")); 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> { fn mcp_server_dir_candidates(dev_path: &Path, resource_roots: &[PathBuf]) -> Vec<PathBuf> {
let current_dir = std::env::current_dir().ok(); let current_dir = std::env::current_dir().ok();
let current_exe = std::env::current_exe().ok();
mcp_server_dir_candidates_for( mcp_server_dir_candidates_for(dev_path, resource_roots, current_dir.as_deref())
dev_path,
resource_roots,
current_dir.as_deref(),
current_exe.as_deref(),
)
} }
fn mcp_server_dir_candidates_for( fn mcp_server_dir_candidates_for(
dev_path: &Path, dev_path: &Path,
resource_roots: &[PathBuf], resource_roots: &[PathBuf],
current_dir: Option<&Path>, current_dir: Option<&Path>,
current_exe: Option<&Path>,
) -> Vec<PathBuf> { ) -> Vec<PathBuf> {
let mut candidates = Vec::new(); let mut candidates = Vec::new();
push_unique_path(&mut candidates, dev_path.to_path_buf()); 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); push_resource_root_candidates(&mut candidates, root);
} }
if let Some(current_exe) = current_exe { for root in runtime_development_roots(current_dir) {
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) {
push_development_root_candidates(&mut candidates, &root); 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) { 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("mcp-server"));
push_unique_path(candidates, root.join("resources").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( fn runtime_development_roots(current_dir: Option<&Path>) -> Vec<PathBuf> {
current_dir: Option<&Path>,
current_exe: Option<&Path>,
) -> Vec<PathBuf> {
let mut roots = Vec::new(); let mut roots = Vec::new();
if let Some(current_dir) = current_dir { if let Some(current_dir) = current_dir {
push_ancestor_paths(&mut roots, 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 roots
} }
@@ -1084,13 +1048,7 @@ mod tests {
fn mcp_server_dir_candidates_include_runtime_dev_roots_when_build_path_is_stale() { 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 stale_dev_path = Path::new("/Users/runner/work/tolaria/tolaria/mcp-server");
let current_dir = Path::new("/Users/luca/Workspace/tolaria"); 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));
let candidates = mcp_server_dir_candidates_for(
stale_dev_path,
&[],
Some(current_dir),
Some(current_exe),
);
assert!(candidates.contains(&PathBuf::from("/Users/luca/Workspace/tolaria/mcp-server"))); assert!(candidates.contains(&PathBuf::from("/Users/luca/Workspace/tolaria/mcp-server")));
assert!(candidates.contains(&PathBuf::from( assert!(candidates.contains(&PathBuf::from(
@@ -1101,8 +1059,10 @@ mod tests {
#[test] #[test]
fn mcp_server_dir_candidates_include_macos_bundle_resources() { fn mcp_server_dir_candidates_include_macos_bundle_resources() {
let dev_path = Path::new("/repo/mcp-server"); let dev_path = Path::new("/repo/mcp-server");
let current_exe = Path::new("/Applications/Tolaria.app/Contents/MacOS/Tolaria"); let resource_roots = vec![PathBuf::from(
let candidates = mcp_server_dir_candidates_for(dev_path, &[], None, Some(current_exe)); "/Applications/Tolaria.app/Contents/Resources",
)];
let candidates = mcp_server_dir_candidates_for(dev_path, &resource_roots, None);
assert!(candidates.contains(&PathBuf::from( assert!(candidates.contains(&PathBuf::from(
"/Applications/Tolaria.app/Contents/Resources/mcp-server" "/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> { pub(super) fn runtime_resource_roots() -> Vec<PathBuf> {
let local_app_data = if cfg!(windows) { let local_app_data = if cfg!(windows) {
@@ -10,7 +10,6 @@ pub(super) fn runtime_resource_roots() -> Vec<PathBuf> {
runtime_resource_roots_for_env( runtime_resource_roots_for_env(
non_empty_env_path("RESOURCEPATH"), non_empty_env_path("RESOURCEPATH"),
non_empty_env_path("APPDIR"), non_empty_env_path("APPDIR"),
current_exe_dir(),
local_app_data, local_app_data,
) )
} }
@@ -18,7 +17,6 @@ pub(super) fn runtime_resource_roots() -> Vec<PathBuf> {
fn runtime_resource_roots_for_env( fn runtime_resource_roots_for_env(
resource_path: Option<PathBuf>, resource_path: Option<PathBuf>,
appdir: Option<PathBuf>, appdir: Option<PathBuf>,
exe_dir: Option<PathBuf>,
local_app_data: Option<PathBuf>, local_app_data: Option<PathBuf>,
) -> Vec<PathBuf> { ) -> Vec<PathBuf> {
let mut roots = Vec::new(); 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"));
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 { 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"));
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 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) { fn push_resource_root(roots: &mut Vec<PathBuf>, root: PathBuf) {
if !root.as_os_str().is_empty() && !roots.iter().any(|candidate| candidate == &root) { if !root.as_os_str().is_empty() && !roots.iter().any(|candidate| candidate == &root) {
roots.push(root); roots.push(root);
@@ -63,17 +52,13 @@ fn non_empty_env_path(key: &str) -> Option<PathBuf> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::path::Path;
#[test] #[test]
fn includes_windows_install_locations() { fn includes_windows_install_locations() {
let local_app_data = PathBuf::from(r"C:\Users\alex\AppData\Local"); let local_app_data = PathBuf::from(r"C:\Users\alex\AppData\Local");
let install_dir = local_app_data.join("Tolaria"); let install_dir = local_app_data.join("Tolaria");
let roots = runtime_resource_roots_for_env( let roots = runtime_resource_roots_for_env(None, None, Some(local_app_data.clone()));
None,
None,
Some(install_dir.clone()),
Some(local_app_data.clone()),
);
assert_eq!(roots.iter().filter(|root| *root == &install_dir).count(), 1); assert_eq!(roots.iter().filter(|root| *root == &install_dir).count(), 1);
assert!(roots.contains(&local_app_data.join("tolaria"))); assert!(roots.contains(&local_app_data.join("tolaria")));

View File

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

View File

@@ -721,7 +721,7 @@ function App() {
}) })
return changed ? next : prev 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({ const { handleGoBack, handleGoForward, canGoBack, canGoForward, entriesByPath } = useAppNavigation({
entries: visibleEntries, entries: visibleEntries,
@@ -821,8 +821,7 @@ function App() {
notes, notes,
refreshGitModifiedFiles, refreshGitModifiedFiles,
resolvedPath, resolvedPath,
setToastMessage, vault
vault,
]) ])
const aiActivity = useAiActivity({ const aiActivity = useAiActivity({
@@ -903,7 +902,7 @@ function App() {
setToastMessage(`Failed to create folder: ${e}`) setToastMessage(`Failed to create folder: ${e}`)
return false return false
} }
}, [resolvedPath, vault, setToastMessage]) }, [resolvedPath, vault])
const folderActions = useFolderActions({ const folderActions = useFolderActions({
vaultPath: resolvedPath, vaultPath: resolvedPath,
@@ -1169,7 +1168,7 @@ function App() {
const created = await notes.handleCreateType(name) const created = await notes.handleCreateType(name)
if (created) setToastMessage(`Type "${name}" created`) if (created) setToastMessage(`Type "${name}" created`)
return created return created
}, [notes, setToastMessage]) }, [notes])
const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => { const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => {
const trimmed = nextTypeName.trim() const trimmed = nextTypeName.trim()
@@ -1198,7 +1197,7 @@ function App() {
: `Type set to "${resolvedTypeName}"`, : `Type set to "${resolvedTypeName}"`,
) )
return true return true
}, [notes, resolvedPath, setToastMessage, visibleEntries]) }, [notes, resolvedPath, visibleEntries])
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => { const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
const editing = dialogs.editingView const editing = dialogs.editingView
@@ -1229,7 +1228,7 @@ function App() {
setToastMessage(`Could not save view: ${message}`) setToastMessage(`Could not save view: ${message}`)
return false 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 handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>, rootPath?: string) => {
const existing = vault.views.find((view) => viewMatchesSelection(view, viewSelection(filename, rootPath))) 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) const message = err instanceof Error ? err.message : String(err)
setToastMessage(`Could not save view: ${message}`) setToastMessage(`Could not save view: ${message}`)
}) })
}, [handleUpdateViewDefinition, setToastMessage]) }, [handleUpdateViewDefinition])
const handleEditView = useCallback((filename: string, rootPath?: string) => { const handleEditView = useCallback((filename: string, rootPath?: string) => {
const view = vault.views.find((candidate) => viewMatchesSelection(candidate, viewSelection(filename, rootPath))) const view = vault.views.find((candidate) => viewMatchesSelection(candidate, viewSelection(filename, rootPath)))
@@ -1384,7 +1383,7 @@ function App() {
} else { } else {
setToastMessage(result.message) setToastMessage(result.message)
} }
}, [appLocale, settings.release_channel, updateActions, updateStatus.state, setToastMessage]) }, [appLocale, settings.release_channel, updateActions, updateStatus.state])
const handleRepairVault = useCallback(async () => { const handleRepairVault = useCallback(async () => {
if (!resolvedPath) return if (!resolvedPath) return
@@ -1397,7 +1396,7 @@ function App() {
} catch (err) { } catch (err) {
setToastMessage(`Failed to repair vault: ${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') => { const restoreVaultAiGuidance = useCallback(async (successToast: string | null = 'Tolaria AI guidance restored') => {
if (!resolvedPath) return if (!resolvedPath) return
@@ -1410,7 +1409,7 @@ function App() {
} catch (err) { } catch (err) {
setToastMessage(`Failed to restore Tolaria AI guidance: ${err}`) setToastMessage(`Failed to restore Tolaria AI guidance: ${err}`)
} }
}, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage]) }, [refreshVaultAiGuidance, resolvedPath, vault])
const activeCommandEntry = useMemo(() => { const activeCommandEntry = useMemo(() => {
if (!notes.activeTabPath) return null if (!notes.activeTabPath) return null
@@ -1537,7 +1536,7 @@ function App() {
const entries = await reloadVaultForCommand() const entries = await reloadVaultForCommand()
setToastMessage(`Vault reloaded (${entries.length} ${entries.length === 1 ? 'entry' : 'entries'})`) setToastMessage(`Vault reloaded (${entries.length} ${entries.length === 1 ? 'entry' : 'entries'})`)
return entries return entries
}, [reloadVaultForCommand, setToastMessage]) }, [reloadVaultForCommand])
const { const {
activeTab, activeTab,
@@ -1650,7 +1649,7 @@ function App() {
canRestoreDeletedNote: !!activeDeletedFile, canRestoreDeletedNote: !!activeDeletedFile,
}) })
const inboxCount = useMemo(() => filterInboxEntries(visibleEntries, inboxPeriod).length, [visibleEntries, inboxPeriod]) const inboxCount = useMemo(() => filterInboxEntries(visibleEntries, inboxPeriod).length, [visibleEntries])
const aiNoteList = useMemo<NoteListItem[]>(() => { const aiNoteList = useMemo<NoteListItem[]>(() => {
const isInbox = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox' const isInbox = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox'
@@ -1663,7 +1662,7 @@ function App() {
return filtered.map(e => ({ return filtered.map(e => ({
path: e.path, title: e.title, type: e.isA ?? 'Note', path: e.path, title: e.title, type: e.isA ?? 'Note',
})) }))
}, [allNotesFileVisibility, visibleEntries, vault.views, effectiveSelection, inboxPeriod]) }, [allNotesFileVisibility, visibleEntries, vault.views, effectiveSelection])
const aiNoteListFilter = useMemo(() => { const aiNoteListFilter = useMemo(() => {
if (effectiveSelection.kind === 'sectionGroup') return { type: effectiveSelection.type, query: '' } 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 }) { function AddBooleanInput({ value, locale, onChange }: { value: string; locale: AppLocale; onChange: (v: string) => void }) {
const boolVal = value.toLowerCase() === 'true' const boolVal = value.toLowerCase() === 'true'
return ( 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" 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')} onClick={() => onChange(boolVal ? 'false' : 'true')}
data-testid="add-property-boolean-toggle" data-testid="add-property-boolean-toggle"
@@ -69,7 +69,7 @@ function AddDateInput({
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <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" 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" data-testid="add-property-date-trigger"
> >
@@ -95,7 +95,7 @@ function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onC
const [showDropdown, setShowDropdown] = useState(false) const [showDropdown, setShowDropdown] = useState(false)
return ( return (
<span className="relative inline-flex min-w-[60px] flex-1 items-center"> <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" 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)} onClick={() => setShowDropdown(true)}
data-testid="add-property-status-trigger" data-testid="add-property-status-trigger"

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export function ArchivedNoteBanner({ onUnarchive, locale = 'en' }: ArchivedNoteB
> >
<Archive size={13} weight="bold" /> <Archive size={13} weight="bold" />
<span>{translate(locale, 'editor.banner.archived')}</span> <span>{translate(locale, 'editor.banner.archived')}</span>
<button <button type="button"
data-testid="unarchive-btn" data-testid="unarchive-btn"
onClick={onUnarchive} onClick={onUnarchive}
style={{ 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 type { NoteWidthMode, VaultEntry } from '../types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../lib/i18n' import { translate, type AppLocale } from '../lib/i18n'
@@ -1044,18 +1044,32 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
onRenameFilename, onRenameFilename,
...actionProps ...actionProps
}: BreadcrumbBarProps) { }: 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 actionsRef = useRef<HTMLDivElement | null>(null)
const titleRef = useRef<HTMLDivElement | null>(null) const titleRef = useRef<HTMLDivElement | null>(null)
const overflowCollapsed = useBreadcrumbOverflow(titleRef, actionsRef) 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 ( return (
<TooltipProvider> <TooltipProvider>
<div <div
ref={barRef} ref={breadcrumbDragRegionRef}
data-tauri-drag-region data-tauri-drag-region
data-title-hidden="" data-title-hidden=""
onMouseDown={onMouseDown}
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent" className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
style={{ style={{
height: 52, 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' import { isValidCssColor, toHexColor } from '../utils/colorUtils'
/** /**
@@ -58,56 +58,116 @@ export function ColorSwatch({ color, onChange }: {
* Editable text field with an inline color swatch. * Editable text field with an inline color swatch.
* The swatch only appears when the value is a valid CSS color. * The swatch only appears when the value is a valid CSS color.
*/ */
export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCancel }: { interface ColorEditableValueProps {
value: string value: string
isEditing: boolean isEditing: boolean
onStartEdit: () => void onStartEdit: () => void
onSave: (newValue: string) => void onSave: (newValue: string) => void
onCancel: () => void onCancel: () => void
}) { }
export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCancel }: ColorEditableValueProps) {
const [editValue, setEditValue] = useState(value) const [editValue, setEditValue] = useState(value)
const showSwatch = isValidCssColor(isEditing ? editValue : 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) => { const handlePickerChange = useCallback((hex: string) => {
if (isEditing) { if (isEditing) setEditValue(hex)
setEditValue(hex)
}
onSave(hex) onSave(hex)
}, [isEditing, onSave]) }, [isEditing, onSave])
if (isEditing) { if (isEditing) {
return ( return (
<span className="flex w-full items-center gap-1.5"> <EditableColorValue
{showSwatch && <ColorSwatch color={editValue} onChange={handlePickerChange} />} editValue={editValue}
<input onCancel={onCancel}
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary" onChange={setEditValue}
type="text" onPickerChange={handlePickerChange}
value={editValue} onSave={onSave}
onChange={(e) => setEditValue(e.target.value)} showSwatch={showSwatch}
onKeyDown={handleKeyDown} value={value}
onBlur={() => onSave(editValue)} />
autoFocus
data-testid="color-text-input"
/>
</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 ( return (
<span className="inline-flex h-6 min-w-0 items-center gap-1.5"> <span className="inline-flex h-6 min-w-0 items-center gap-1.5">
{showSwatch && <ColorSwatch color={value} onChange={handlePickerChange} />} {showSwatch && <ColorSwatch color={value} onChange={onPickerChange} />}
<span <button
className="min-w-0 cursor-pointer truncate rounded px-1 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted" 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} onClick={onStartEdit}
title={value || 'Click to edit'} title={value || 'Click to edit'}
> >
{value || '\u2014'} {value || '\u2014'}
</span> </button>
</span> </span>
) )
} }

View File

@@ -291,6 +291,7 @@ function OpenCommandPalette({
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const aiInputRef = useRef<HTMLDivElement>(null) const aiInputRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null)
const rootRef = useRef<HTMLDivElement>(null)
const aiMode = aiModeEnabled && aiValue.startsWith(' ') const aiMode = aiModeEnabled && aiValue.startsWith(' ')
const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady
const { groups, flatList } = usePaletteResults(commands, query) const { groups, flatList } = usePaletteResults(commands, query)
@@ -317,6 +318,7 @@ function OpenCommandPalette({
}, [aiMode]) }, [aiMode])
useEffect(() => { useEffect(() => {
void selectedIndex
if (aiMode || !listRef.current) return if (aiMode || !listRef.current) return
const selectedHTMLElement = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | null const selectedHTMLElement = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | null
selectedHTMLElement?.scrollIntoView({ block: 'nearest' }) selectedHTMLElement?.scrollIntoView({ block: 'nearest' })
@@ -358,6 +360,18 @@ function OpenCommandPalette({
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [aiMode, flatList, onClose, selectedIndex]) }, [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) => { const handleQueryChange = (nextQuery: string) => {
setSelectedIndex(0) setSelectedIndex(0)
if (aiModeEnabled && nextQuery.startsWith(' ')) { if (aiModeEnabled && nextQuery.startsWith(' ')) {
@@ -401,16 +415,21 @@ function OpenCommandPalette({
return ( return (
<div <div
ref={rootRef}
data-command-palette="true" data-command-palette="true"
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]" 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 <div
className={cn( 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]', aiMode && 'min-h-[220px]',
)} )}
onClick={(event) => event.stopPropagation()}
> >
{aiMode ? ( {aiMode ? (
<CommandPaletteAiMode <CommandPaletteAiMode
@@ -457,10 +476,11 @@ interface CommandRowProps {
function CommandRow({ command, selected, onHover, onSelect }: CommandRowProps) { function CommandRow({ command, selected, onHover, onSelect }: CommandRowProps) {
return ( return (
<div <button
type="button"
data-selected={selected} data-selected={selected}
className={cn( 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', selected ? 'bg-accent' : 'hover:bg-secondary',
)} )}
onClick={onSelect} onClick={onSelect}
@@ -470,6 +490,6 @@ function CommandRow({ command, selected, onHover, onSelect }: CommandRowProps) {
{command.shortcut && ( {command.shortcut && (
<span className="text-[11px] text-muted-foreground">{command.shortcut}</span> <span className="text-[11px] text-muted-foreground">{command.shortcut}</span>
)} )}
</div> </button>
) )
} }

View File

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

View File

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

View File

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

View File

@@ -87,7 +87,7 @@ function ConflictFileRow({
onOpenInEditor: () => void onOpenInEditor: () => void
onFocus: () => void onFocus: () => void
}) { }) {
const rowRef = useRef<HTMLDivElement>(null) const rowRef = useRef<HTMLTableRowElement>(null)
const binary = isBinaryFile(state.file) const binary = isBinaryFile(state.file)
const resolved = state.resolution !== null const resolved = state.resolution !== null
@@ -96,9 +96,8 @@ function ConflictFileRow({
}, [focused]) }, [focused])
return ( return (
<div <tr
ref={rowRef} ref={rowRef}
role="row"
tabIndex={0} tabIndex={0}
onFocus={onFocus} onFocus={onFocus}
className={cn( className={cn(
@@ -108,12 +107,12 @@ function ConflictFileRow({
)} )}
data-testid={`conflict-file-${state.file}`} 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" /> <FileText size={14} className="shrink-0 text-muted-foreground" />
<span className="text-sm truncate" title={state.file}>{fileName(state.file)}</span> <span className="text-sm truncate" title={state.file}>{fileName(state.file)}</span>
<ResolutionLabel resolution={state.resolution} /> <ResolutionLabel resolution={state.resolution} />
</div> </td>
<div className="flex items-center gap-1 shrink-0"> <td className="flex shrink-0 items-center gap-1">
{state.resolving ? ( {state.resolving ? (
<Loader2 size={14} className="animate-spin text-muted-foreground" /> <Loader2 size={14} className="animate-spin text-muted-foreground" />
) : ( ) : (
@@ -154,8 +153,8 @@ function ConflictFileRow({
)} )}
</> </>
)} )}
</div> </td>
</div> </tr>
) )
} }
@@ -290,22 +289,23 @@ function ConflictFileList({
onResolveFile: (file: string, strategy: ConflictResolutionStrategy) => void onResolveFile: (file: string, strategy: ConflictResolutionStrategy) => void
}) { }) {
return ( return (
<div <table
className="flex flex-col gap-2 max-h-[300px] overflow-y-auto" className="block max-h-[300px] overflow-y-auto"
role="grid"
data-testid="conflict-file-list" data-testid="conflict-file-list"
> >
{fileStates.map((state, index) => ( <tbody className="flex flex-col gap-2">
<ConflictFileRow {fileStates.map((state, index) => (
key={state.file} <ConflictFileRow
state={state} key={state.file}
focused={index === focusIdx} state={state}
onResolve={(strategy) => onResolveFile(state.file, strategy)} focused={index === focusIdx}
onOpenInEditor={() => onOpenInEditor(state.file)} onResolve={(strategy) => onResolveFile(state.file, strategy)}
onFocus={() => onFocusRow(index)} onOpenInEditor={() => onOpenInEditor(state.file)}
/> 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -30,6 +30,7 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [type, setType] = useState<string>('Note') const [type, setType] = useState<string>('Note')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const titleInputId = useId()
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -58,10 +59,11 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5"> <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 Title
</label> </label>
<Input <Input
id={titleInputId}
ref={inputRef} ref={inputRef}
placeholder="Enter note title..." placeholder="Enter note title..."
value={title} value={title}
@@ -69,9 +71,9 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
/> />
</div> </div>
<div className="space-y-1.5"> <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 Type
</label> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{BUILT_IN_TYPES.map((t) => ( {BUILT_IN_TYPES.map((t) => (
<Button <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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -18,6 +18,7 @@ interface CreateTypeDialogFormProps {
function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDialogFormProps) { function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDialogFormProps) {
const [name, setName] = useState(initialName) const [name, setName] = useState(initialName)
const nameInputId = useId()
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -30,10 +31,11 @@ function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDial
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5"> <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 Type Name
</label> </label>
<Input <Input
id={nameInputId}
autoFocus autoFocus
placeholder="e.g. Recipe, Book, Habit..." placeholder="e.g. Recipe, Book, Habit..."
value={name} 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -48,6 +48,7 @@ function CreateViewDialogForm({
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const nameInputId = useId()
useEffect(() => { useEffect(() => {
const timeoutId = window.setTimeout(() => inputRef.current?.focus(), 50) const timeoutId = window.setTimeout(() => inputRef.current?.focus(), 50)
@@ -90,8 +91,9 @@ function CreateViewDialogForm({
return ( return (
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4"> <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<div className="space-y-1.5"> <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 <Input
id={nameInputId}
ref={inputRef} ref={inputRef}
placeholder={translate(locale, 'viewDialog.namePlaceholder')} placeholder={translate(locale, 'viewDialog.namePlaceholder')}
value={name} value={name}
@@ -105,7 +107,7 @@ function CreateViewDialogForm({
<p role="alert" className="text-xs text-destructive">{saveError}</p> <p role="alert" className="text-xs text-destructive">{saveError}</p>
)} )}
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto"> <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 <FilterBuilder
group={filters} group={filters}
onChange={setFilters} onChange={setFilters}

View File

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

View File

@@ -44,23 +44,13 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
locale: AppLocale locale: AppLocale
}) { }) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.target !== e.currentTarget) {
return
}
if (e.key === 'Enter' && editingKey !== propKey) {
e.preventDefault()
onStartEdit(propKey)
}
}
return ( 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}> <span className={PROPERTY_PANEL_LABEL_CLASS_NAME}>
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} /> <DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
<span className="min-w-0 flex-1 truncate">{humanizePropertyKey(propKey)}</span> <span className="min-w-0 flex-1 truncate">{humanizePropertyKey(propKey)}</span>
{onDelete && ( {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> </span>
<div className="min-w-0"> <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 { normalizeUrl, openExternalUrl } from '../utils/url'
import { getTagStyle } from '../utils/tagStyles' import { getTagStyle } from '../utils/tagStyles'
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles' 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({ export function UrlValue({
value, value,
onSave, onSave,
@@ -52,29 +62,29 @@ export function UrlValue({
if (isEditing) { if (isEditing) {
return ( 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" className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
type="text" type="text"
value={editValue} value={editValue}
onChange={(e) => setEditValue(e.target.value)} onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={() => onSave(editValue)} onBlur={() => onSave(editValue)}
autoFocus
/> />
) )
} }
return ( return (
<span className="group/url flex w-full min-w-0 items-center gap-1"> <span className="group/url flex w-full min-w-0 items-center gap-1">
<span <button
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)]" 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} onClick={handleOpen}
title={value} title={value}
data-testid="url-link" data-testid="url-link"
> >
<span className="min-w-0 truncate">{value || '\u2014'}</span> <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" 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} onClick={handleEditClick}
title="Edit URL" title="Edit URL"
@@ -112,26 +122,26 @@ export function EditableValue({
if (isEditing) { if (isEditing) {
return ( 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" className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
type="text" type="text"
value={editValue} value={editValue}
onChange={(e) => setEditValue(e.target.value)} onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={() => onSave(editValue)} onBlur={() => onSave(editValue)}
autoFocus
/> />
) )
} }
return ( return (
<span <button
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" 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} onClick={onStartEdit}
title={value || 'Click to edit'} title={value || 'Click to edit'}
> >
<span className="min-w-0 truncate">{value || '\u2014'}</span> <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"> <div className="flex flex-wrap items-center gap-1">
{items.map((item, idx) => {items.map((item, idx) =>
editingIndex === idx ? ( editingIndex === idx ? (
<input <AutoFocusInput
key={idx} key={item}
className="rounded-full border border-ring bg-muted px-2 py-0.5 text-[11px] text-foreground outline-none focus:border-primary" 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) }} style={{ width: Math.max(60, editValue.length * 7 + 16) }}
type="text" type="text"
@@ -208,22 +218,26 @@ export function TagPillList({
onChange={(e) => setEditValue(e.target.value)} onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => handleKeyDown(e, 'edit')} onKeyDown={(e) => handleKeyDown(e, 'edit')}
onBlur={handleSaveEdit} onBlur={handleSaveEdit}
autoFocus
/> />
) : ( ) : (
<span <span
key={idx} key={item}
className="group/pill relative inline-flex max-w-full min-w-0 cursor-pointer items-center overflow-hidden transition-colors" className="group/pill relative inline-flex max-w-full min-w-0 items-center overflow-hidden transition-colors"
style={{ style={{
...PROPERTY_CHIP_STYLE, ...PROPERTY_CHIP_STYLE,
backgroundColor: getTagStyle(item).bg, backgroundColor: getTagStyle(item).bg,
color: getTagStyle(item).color, color: getTagStyle(item).color,
}} }}
onClick={() => handleStartEdit(idx)}
title="Click to edit" title="Click to edit"
> >
<span className="min-w-0 truncate pr-4">{item}</span>
<button <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" 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 }} style={{ color: getTagStyle(item).color, backgroundColor: getTagStyle(item).bg }}
onClick={(e) => { onClick={(e) => {
@@ -238,7 +252,7 @@ export function TagPillList({
) )
)} )}
{isAddingNew ? ( {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" 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) }} style={{ width: Math.max(60, newValue.length * 7 + 16) }}
type="text" type="text"
@@ -250,10 +264,9 @@ export function TagPillList({
else { setIsAddingNew(false); setNewValue('') } else { setIsAddingNew(false); setNewValue('') }
}} }}
placeholder={`${label}...`} 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" 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} style={PROPERTY_CHIP_STYLE}
onClick={() => setIsAddingNew(true)} onClick={() => setIsAddingNew(true)}

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,7 @@ interface FolderTreeBodyProps extends Pick<
rootPath?: string rootPath?: string
sectionCollapsed: boolean sectionCollapsed: boolean
toggleFolder: (path: string) => void 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 { function vaultRootLabel(vaultRootPath: string, locale: AppLocale): string {

View File

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

View File

@@ -169,13 +169,14 @@ export function IconEditableValue({
} }
return ( return (
<span <button
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" 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} onClick={onStartEdit}
title={value || 'Click to edit'} title={value || 'Click to edit'}
data-testid="icon-editable-display" data-testid="icon-editable-display"
> >
{value || '\u2014'} {value || '\u2014'}
</span> </button>
) )
} }

View File

@@ -225,6 +225,7 @@ export function InlineWikilinkInput({
const handledFileDropRef = useRef(false) const handledFileDropRef = useRef(false)
const pendingFocusAfterRemountRef = useRef<InlineSelectionRange | null>(null) const pendingFocusAfterRemountRef = useRef<InlineSelectionRange | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
void renderVersion
const target = pendingFocusAfterRemountRef.current const target = pendingFocusAfterRemountRef.current
if (!target) return if (!target) return
pendingFocusAfterRemountRef.current = null pendingFocusAfterRemountRef.current = null
@@ -376,6 +377,7 @@ export function InlineWikilinkInput({
notifyUnsupportedPaste() notifyUnsupportedPaste()
}, [disabled, insertTransferText, notifyUnsupportedPaste]) }, [disabled, insertTransferText, notifyUnsupportedPaste])
useLayoutEffect(() => { useLayoutEffect(() => {
void renderVersion
const editor = editorRef.current const editor = editorRef.current
if (!editor) return 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 { CSSProperties } from 'react'
import type { VaultEntry } from '../types' import type { VaultEntry } from '../types'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
@@ -11,6 +11,17 @@ import type {
import type { InlineWikilinkSuggestion } from './inlineWikilinkSuggestions' import type { InlineWikilinkSuggestion } from './inlineWikilinkSuggestions'
import { cn } from '@/lib/utils' 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({ export function InlineWikilinkChipView({
chip, chip,
typeEntryMap, typeEntryMap,
@@ -72,16 +83,17 @@ function InlineSuggestionRow({
const typeIcon = getTypeIcon(suggestion.entry.isA, typeEntry?.icon) const typeIcon = getTypeIcon(suggestion.entry.isA, typeEntry?.icon)
return ( return (
<div <button
type="button"
className={cn( 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', selected ? 'bg-accent' : 'hover:bg-secondary',
)} )}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
onClick={onSelect} onClick={onSelect}
onMouseEnter={onHover} onMouseEnter={onHover}
> >
<div className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span <span
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full" className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor, color }} style={{ backgroundColor, color }}
@@ -98,11 +110,11 @@ function InlineSuggestionRow({
)} )}
</span> </span>
<span className="truncate text-sm text-foreground">{suggestion.title}</span> <span className="truncate text-sm text-foreground">{suggestion.title}</span>
</div> </span>
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground"> <span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
{suggestion.entry.isA ?? 'Note'} {suggestion.entry.isA ?? 'Note'}
</span> </span>
</div> </button>
) )
} }
@@ -189,7 +201,20 @@ export function InlineWikilinkEditorField({
segments: InlineWikilinkSegment[] segments: InlineWikilinkSegment[]
typeEntryMap: Record<string, VaultEntry> typeEntryMap: Record<string, VaultEntry>
}) { }) {
const editorRef = useRef<HTMLDivElement | null>(null)
const needsTrailingCaretAnchor = segments[segments.length - 1]?.kind === 'chip' 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 ( return (
<div className="relative"> <div className="relative">
@@ -202,48 +227,105 @@ export function InlineWikilinkEditorField({
</div> </div>
)} )}
<div <div
ref={inputRef} ref={editorRef}
contentEditable={!disabled} contentEditable={!disabled}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
role="textbox" aria-disabled={disabled ? 'true' : undefined}
aria-multiline="true"
aria-disabled={disabled || undefined}
aria-placeholder={placeholder}
data-testid={dataTestId} data-testid={dataTestId}
className={cn( className={cn(
'min-h-[34px] w-full rounded-lg border border-border bg-transparent px-[10px] py-[8px] text-[13px] text-foreground outline-none', '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', disabled && 'cursor-not-allowed opacity-60',
editorClassName, 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' }} style={{ ...editorStyle, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
> >
{segments.map((segment, index) => ( {segments.map((segment) => renderInlineWikilinkSegment(segment, typeEntryMap))}
segment.kind === 'text'
? <Fragment key={`text-${index}`}>{segment.text}</Fragment>
: (
<InlineWikilinkChipView
key={`chip-${segment.chip.entry.path}-${segment.chip.target}`}
chip={segment.chip}
typeEntryMap={typeEntryMap}
/>
)
))}
{needsTrailingCaretAnchor ? '\u200B' : null} {needsTrailingCaretAnchor ? '\u200B' : null}
</div> </div>
</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}
/>
)
}
export function InlineWikilinkPaletteLayout({ export function InlineWikilinkPaletteLayout({
header, header,
editor, editor,

View File

@@ -21,7 +21,9 @@ vi.mock('@tauri-apps/api/window', () => ({
async function openSubmenu(label: string) { async function openSubmenu(label: string) {
fireEvent.pointerDown(screen.getByRole('button', { name: 'Application menu' }), { button: 0 }) 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.pointerMove(trigger)
fireEvent.click(trigger) fireEvent.click(trigger)
} }

View File

@@ -47,12 +47,20 @@ function triggerMenuCommand(menuItemId: string): void {
void invoke('trigger_menu_command', { id: menuItemId }).catch(() => {}) 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 }) { function MenuSectionItems({ section }: { section: MenuSection }) {
return ( return (
<> <>
{section.items.map((item, index) => { {section.items.map((item) => {
if (item.kind === 'separator') { if (item.kind === 'separator') {
return <DropdownMenuSeparator key={`${section.label}-${index}`} /> return <DropdownMenuSeparator key={menuSeparatorKey(section, item)} />
} }
if (item.kind === 'command') { if (item.kind === 'command') {
@@ -82,7 +90,7 @@ function MenuSectionItems({ section }: { section: MenuSection }) {
function HamburgerIcon() { function HamburgerIcon() {
return ( 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="4" x2="12" y2="4" />
<line x1="2" y1="7" x2="12" y2="7" /> <line x1="2" y1="7" x2="12" y2="7" />
<line x1="2" y1="10" x2="12" y2="10" /> <line x1="2" y1="10" x2="12" y2="10" />

View File

@@ -37,7 +37,7 @@ const RESIZE_HANDLES: ReadonlyArray<{
export function LinuxTitlebar() { export function LinuxTitlebar() {
const customChromeEnabled = shouldUseCustomWindowChrome() const customChromeEnabled = shouldUseCustomWindowChrome()
const { onMouseDown } = useDragRegion() const { dragRegionRef } = useDragRegion<HTMLDivElement>()
const maximized = useLinuxMaximizedState(customChromeEnabled) const maximized = useLinuxMaximizedState(customChromeEnabled)
if (!customChromeEnabled) return null if (!customChromeEnabled) return null
@@ -48,9 +48,9 @@ export function LinuxTitlebar() {
<> <>
<ResizeHandles /> <ResizeHandles />
<div <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" 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 }} style={{ height: LINUX_TITLEBAR_HEIGHT }}
onMouseDown={onMouseDown}
data-testid="linux-titlebar" data-testid="linux-titlebar"
> >
<div className="flex h-full items-center" data-no-drag> <div className="flex h-full items-center" data-no-drag>
@@ -175,7 +175,7 @@ function TitlebarButton({
function MinimizeIcon() { function MinimizeIcon() {
return ( 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" /> <line x1="2.5" y1="6" x2="9.5" y2="6" />
</svg> </svg>
) )
@@ -183,7 +183,7 @@ function MinimizeIcon() {
function MaximizeIcon() { function MaximizeIcon() {
return ( 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" /> <rect x="2.5" y="2.5" width="7" height="7" rx="0.5" />
</svg> </svg>
) )
@@ -191,7 +191,7 @@ function MaximizeIcon() {
function RestoreIcon() { function RestoreIcon() {
return ( 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" /> <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" /> <path d="M4 3.8 V 2.5 H 9.5 V 8" />
</svg> </svg>
@@ -200,7 +200,7 @@ function RestoreIcon() {
function CloseIcon() { function CloseIcon() {
return ( 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="3" y1="3" x2="9" y2="9" />
<line x1="9" y1="3" x2="3" y2="9" /> <line x1="9" y1="3" x2="3" y2="9" />
</svg> </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 Markdown, { defaultUrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
@@ -24,14 +24,6 @@ export const MarkdownContent = memo(function MarkdownContent({ content, onWikili
[content, onWikilinkClick], [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(() => { const components = useMemo(() => {
if (!onWikilinkClick) return undefined if (!onWikilinkClick) return undefined
return { return {
@@ -39,9 +31,21 @@ export const MarkdownContent = memo(function MarkdownContent({ content, onWikili
if (href?.startsWith(WIKILINK_SCHEME)) { if (href?.startsWith(WIKILINK_SCHEME)) {
const target = decodeURIComponent(href.slice(WIKILINK_SCHEME.length)) const target = decodeURIComponent(href.slice(WIKILINK_SCHEME.length))
return ( 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} {children}
</span> </a>
) )
} }
return <a href={href}>{children}</a> return <a href={href}>{children}</a>
@@ -50,7 +54,7 @@ export const MarkdownContent = memo(function MarkdownContent({ content, onWikili
}, [onWikilinkClick]) }, [onWikilinkClick])
return ( return (
<div className="ai-markdown" onClick={onWikilinkClick ? handleClick : undefined} role="presentation"> <div className="ai-markdown">
<Markdown <Markdown
remarkPlugins={REMARK_PLUGINS} remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS} rehypePlugins={REHYPE_PLUGINS}

View File

@@ -97,7 +97,6 @@ function ManualMcpConfigSection(props: ManualMcpConfigSectionProps) {
</Button> </Button>
</div> </div>
<pre <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" 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" data-testid="mcp-config-snippet"
> >

View File

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

View File

@@ -1,5 +1,5 @@
import { ArrowsOut as Maximize2 } from '@phosphor-icons/react' 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 { Button } from '@/components/ui/button'
import { import {
Dialog, 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) { export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
const reactId = useId() const reactId = useId()
const renderId = useMemo(() => renderIdFromReactId(reactId), [reactId]) const renderId = useMemo(() => renderIdFromReactId(reactId), [reactId])
@@ -183,7 +193,7 @@ export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
return ( return (
<figure className="mermaid-diagram mermaid-diagram--error" data-testid="mermaid-diagram-error"> <figure className="mermaid-diagram mermaid-diagram--error" data-testid="mermaid-diagram-error">
<figcaption>Mermaid diagram unavailable</figcaption> <figcaption>Mermaid diagram unavailable</figcaption>
<pre aria-label="Mermaid source"><code>{source}</code></pre> <MermaidSourceFallback source={source} />
</figure> </figure>
) )
} }

View File

@@ -207,7 +207,8 @@ function NoteAutocompleteMenuItem({
onHover, onHover,
}: NoteAutocompleteMenuItemProps) { }: NoteAutocompleteMenuItemProps) {
return ( return (
<div <button
type="button"
className={`wikilink-menu__item${selected ? ' wikilink-menu__item--selected' : ''}`} className={`wikilink-menu__item${selected ? ' wikilink-menu__item--selected' : ''}`}
onMouseDown={preventAutocompleteMouseDown} onMouseDown={preventAutocompleteMouseDown}
onClick={() => onSelect(item.title)} onClick={() => onSelect(item.title)}
@@ -224,7 +225,7 @@ function NoteAutocompleteMenuItem({
</span> </span>
)} )}
<WorkspaceInitialsBadge workspace={item.workspace} testId="note-autocomplete-workspace-badge" /> <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 ( return (
<div className="note-icon-area" data-testid="note-icon-area" style={{ position: 'relative' }}> <div className="note-icon-area" data-testid="note-icon-area" style={{ position: 'relative' }}>
{hasIcon ? ( {hasIcon ? (
<button <button type="button"
className="note-icon-button note-icon-button--active" className="note-icon-button note-icon-button--active"
onClick={handleActivate} onClick={handleActivate}
data-testid="note-icon-display" data-testid="note-icon-display"
@@ -38,7 +38,7 @@ export function NoteIcon({ icon, editable = true }: NoteIconProps) {
</button> </button>
) : ( ) : (
editable && ( editable && (
<button <button type="button"
className="note-icon-button note-icon-button--add" className="note-icon-button note-icon-button--add"
onClick={handleActivate} onClick={handleActivate}
data-testid="note-icon-add" data-testid="note-icon-add"

View File

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

View File

@@ -46,7 +46,7 @@ function NoteSearchListItem<T extends NoteSearchResultItem>({
}: NoteSearchListItemProps<T>) { }: NoteSearchListItemProps<T>) {
const pressActivatedRef = useRef(false) const pressActivatedRef = useRef(false)
const activateFromPress = (event: MouseEvent<HTMLDivElement> | PointerEvent<HTMLDivElement>) => { const activateFromPress = (event: MouseEvent<HTMLButtonElement> | PointerEvent<HTMLButtonElement>) => {
event.preventDefault() event.preventDefault()
if (!activateOnMouseDown) return if (!activateOnMouseDown) return
@@ -60,7 +60,7 @@ function NoteSearchListItem<T extends NoteSearchResultItem>({
onItemClick(item, index) onItemClick(item, index)
} }
const handleClick = (event: MouseEvent<HTMLDivElement>) => { const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (activateOnMouseDown) { if (activateOnMouseDown) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -73,40 +73,45 @@ function NoteSearchListItem<T extends NoteSearchResultItem>({
return ( return (
<div <div
className={cn( 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', selected ? 'bg-accent' : 'hover:bg-secondary',
)} )}
onPointerDownCapture={activateFromPress}
onMouseDownCapture={activateFromPress}
onClick={handleClick}
onMouseEnter={() => onItemHover?.(index)}
> >
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm text-foreground"> <button
{item.TypeIcon && ( type="button"
<item.TypeIcon className="flex w-full items-center justify-between gap-2 border-0 bg-transparent px-3 py-1.5 text-left"
width={14} onPointerDownCapture={activateFromPress}
height={14} onMouseDownCapture={activateFromPress}
className="shrink-0" onClick={handleClick}
style={item.typeColor ? { color: item.typeColor } : undefined} onMouseEnter={() => onItemHover?.(index)}
/> >
)} <span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm text-foreground">
<NoteTitleIcon icon={item.noteIcon} size={14} testId="note-search-item-icon" /> {item.TypeIcon && (
<span className="truncate">{item.title}</span> <item.TypeIcon
</span> width={14}
{(item.noteType || item.workspace) && ( height={14}
<span className="ml-2 flex shrink-0 items-center gap-1.5"> className="shrink-0"
{item.noteType && ( style={item.typeColor ? { color: item.typeColor } : undefined}
<Badge />
variant="secondary"
className="shrink-0 text-[11px]"
style={item.typeColor ? { color: item.typeColor, backgroundColor: item.typeLightColor } : undefined}
>
{item.noteType}
</Badge>
)} )}
<WorkspaceInitialsBadge workspace={item.workspace} testId="note-search-workspace-badge" /> <NoteTitleIcon icon={item.noteIcon} size={14} testId="note-search-item-icon" />
<span className="truncate">{item.title}</span>
</span> </span>
)} {(item.noteType || item.workspace) && (
<span className="ml-2 flex shrink-0 items-center gap-1.5">
{item.noteType && (
<Badge
variant="secondary"
className="shrink-0 text-[11px]"
style={item.typeColor ? { color: item.typeColor, backgroundColor: item.typeLightColor } : undefined}
>
{item.noteType}
</Badge>
)}
<WorkspaceInitialsBadge workspace={item.workspace} testId="note-search-workspace-badge" />
</span>
)}
</button>
</div> </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 { cn } from '@/lib/utils'
import { useDragRegion } from '../hooks/useDragRegion' import { useDragRegion } from '../hooks/useDragRegion'
@@ -19,14 +19,28 @@ export function OnboardingShell({
style, style,
testId, testId,
}: OnboardingShellProps) { }: 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 ( return (
<div <div
ref={shellRef}
className={cn('flex h-full w-full items-center justify-center px-6 py-8', className)} className={cn('flex h-full w-full items-center justify-center px-6 py-8', className)}
style={style} style={style}
data-testid={testId} data-testid={testId}
onMouseDown={onMouseDown}
> >
<div className={contentClassName} style={contentStyle} data-no-drag> <div className={contentClassName} style={contentStyle} data-no-drag>
{children} {children}

View File

@@ -1,3 +1,4 @@
import { useId } from 'react'
import type { createTranslator } from '../lib/i18n' import type { createTranslator } from '../lib/i18n'
import { SectionHeading, SettingsGroup, SettingsGroupItem } from './SettingsControls' import { SectionHeading, SettingsGroup, SettingsGroupItem } from './SettingsControls'
import { Checkbox } from './ui/checkbox' import { Checkbox } from './ui/checkbox'
@@ -29,10 +30,12 @@ function TelemetryToggle({
onChange: (value: boolean) => void onChange: (value: boolean) => void
testId: string testId: string
}) { }) {
const checkboxId = useId()
return ( return (
<SettingsGroupItem testId={testId}> <SettingsGroupItem testId={testId}>
<label className="flex cursor-pointer items-start gap-3"> <label htmlFor={checkboxId} className="flex cursor-pointer items-start gap-3">
<Checkbox checked={checked} onCheckedChange={(value) => onChange(isChecked(value))} className="mt-0.5" /> <Checkbox id={checkboxId} checked={checked} onCheckedChange={(value) => onChange(isChecked(value))} className="mt-0.5" />
<span className="space-y-1"> <span className="space-y-1">
<span className="block text-sm font-medium text-foreground">{label}</span> <span className="block text-sm font-medium text-foreground">{label}</span>
<span className="block text-xs leading-5 text-muted-foreground">{description}</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: createValueButtonMock:
(testId: string, nextValue: (value: string) => string) => (testId: string, nextValue: (value: string) => string) =>
({ value, onSave }: { value: string; onSave: (value: string) => void }) => ( ({ 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} {value}
</button> </button>
), ),
@@ -32,7 +32,7 @@ vi.mock('./EditableValue', () => ({
label: string label: string
onSave: (items: string[]) => void 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(',')} {label}:{items.join(',')}
</button> </button>
), ),
@@ -48,8 +48,8 @@ vi.mock('./StatusDropdown', () => ({
onCancel: () => void onCancel: () => void
}) => ( }) => (
<div> <div>
<button data-testid="status-save" onClick={() => onSave('Done')}>save</button> <button type="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-cancel" onClick={onCancel}>cancel</button>
</div> </div>
), ),
})) }))
@@ -63,9 +63,9 @@ vi.mock('./TagsDropdown', () => ({
onClose: () => void onClose: () => void
}) => ( }) => (
<div data-testid="tags-dropdown"> <div data-testid="tags-dropdown">
<button data-testid="tags-toggle-alpha" onClick={() => onToggle('alpha')}>alpha</button> <button type="button" data-testid="tags-toggle-alpha" onClick={() => onToggle('alpha')}>alpha</button>
<button data-testid="tags-toggle-beta" onClick={() => onToggle('beta')}>beta</button> <button type="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-close" onClick={onClose}>close</button>
</div> </div>
), ),
})) }))
@@ -95,10 +95,10 @@ vi.mock('@/components/ui/calendar', () => ({
calendarMockState.props.push({ captionLayout, navLayout, startMonth, endMonth }) calendarMockState.props.push({ captionLayout, navLayout, startMonth, endMonth })
return ( return (
<div> <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 pick
</button> </button>
<button data-testid="date-picker-empty" onClick={() => onSelect(undefined)}> <button type="button" data-testid="date-picker-empty" onClick={() => onSelect(undefined)}>
empty empty
</button> </button>
</div> </div>
@@ -116,7 +116,7 @@ vi.mock('@/components/ui/popover', () => ({
}) => ( }) => (
<div> <div>
{children} {children}
<button data-testid="popover-close" onClick={() => onOpenChange?.(false)}>close</button> <button type="button" data-testid="popover-close" onClick={() => onOpenChange?.(false)}>close</button>
</div> </div>
), ),
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>, PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,

View File

@@ -8,7 +8,7 @@ const { createDropdownModule, createPrimarySecondaryActions } = vi.hoisted(() =>
) => ( ) => (
<div data-testid={containerTestId}> <div data-testid={containerTestId}>
{actions.map(({ testId, label, onClick }) => ( {actions.map(({ testId, label, onClick }) => (
<button key={testId} data-testid={testId} onClick={onClick}> <button type="button" key={testId} data-testid={testId} onClick={onClick}>
{label} {label}
</button> </button>
))} ))}
@@ -38,7 +38,7 @@ const { createDropdownModule, createPrimarySecondaryActions } = vi.hoisted(() =>
vi.mock('./EditableValue', () => ({ vi.mock('./EditableValue', () => ({
EditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => ( 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} {value}
</button> </button>
), ),
@@ -51,12 +51,12 @@ vi.mock('./EditableValue', () => ({
label: string label: string
onSave: (items: string[]) => void 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(',')} {label}:{items.join(',')}
</button> </button>
), ),
UrlValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => ( 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} {value}
</button> </button>
), ),
@@ -80,7 +80,7 @@ vi.mock('./TagsDropdown', () =>
vi.mock('./ColorInput', () => ({ vi.mock('./ColorInput', () => ({
ColorEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => ( 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} {value}
</button> </button>
), ),
@@ -88,7 +88,7 @@ vi.mock('./ColorInput', () => ({
vi.mock('./IconEditableValue', () => ({ vi.mock('./IconEditableValue', () => ({
IconEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => ( 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} {value}
</button> </button>
), ),
@@ -96,7 +96,7 @@ vi.mock('./IconEditableValue', () => ({
vi.mock('@/components/ui/calendar', () => ({ vi.mock('@/components/ui/calendar', () => ({
Calendar: ({ onSelect }: { onSelect: (value: Date) => void }) => ( Calendar: ({ onSelect }: { onSelect: (value: Date) => void }) => (
<button <button type="button"
data-testid="date-picker-calendar" data-testid="date-picker-calendar"
onClick={() => onSelect(new Date(2026, 3, 22))} onClick={() => onSelect(new Date(2026, 3, 22))}
> >

View File

@@ -1,5 +1,5 @@
import { ArrowUpRight, X as XIcon } from '@phosphor-icons/react' 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 { createPortal } from 'react-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar' import { Calendar } from '@/components/ui/calendar'
@@ -104,15 +104,16 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
const style = getStatusStyle(statusStr) const style = getStatusStyle(statusStr)
return ( return (
<span className="relative inline-flex min-w-0 items-center"> <span className="relative inline-flex min-w-0 items-center">
<span <button
className="inline-flex cursor-pointer items-center gap-1.5 transition-opacity hover:opacity-80" 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 }} style={{ ...PROPERTY_CHIP_STYLE, backgroundColor: style.bg, color: style.color }}
onClick={() => onStartEdit(propKey)} onClick={() => onStartEdit(propKey)}
data-testid="status-badge" data-testid="status-badge"
> >
<span className="inline-block size-1.5 shrink-0 rounded-full" style={{ backgroundColor: style.color }} /> <span className="inline-block size-1.5 shrink-0 rounded-full" style={{ backgroundColor: style.color }} />
{statusStr} {statusStr}
</span> </button>
{isEditing && ( {isEditing && (
<StatusDropdown <StatusDropdown
value={statusStr} value={statusStr}
@@ -159,7 +160,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
> >
{tag} {tag}
</span> </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" 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 }} style={{ color: style.color, fontSize: 11, flexShrink: 0 }}
onClick={() => handleRemove(tag)} onClick={() => handleRemove(tag)}
@@ -170,7 +171,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
</span> </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" 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} style={PROPERTY_CHIP_STYLE}
onClick={() => onStartEdit(propKey)} 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 CurrentIcon = Reflect.get(DISPLAY_MODE_ICONS, currentMode) as typeof DISPLAY_MODE_ICONS.text
const showRelationshipIcon = showsRelationshipPropertyIcon(propKey) 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) => { const handleSelect = (mode: PropertyDisplayMode) => {
if (mode === autoMode) { onSelect(propKey, mode === autoMode ? null : mode)
onSelect(propKey, null)
} else {
onSelect(propKey, mode)
}
setOpen(false) setOpen(false)
} }
@@ -468,42 +453,109 @@ export function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }
<CurrentIcon className="size-3.5" data-testid={`display-mode-icon-${currentMode}`} /> <CurrentIcon className="size-3.5" data-testid={`display-mode-icon-${currentMode}`} />
)} )}
</button> </button>
{open && createPortal( {open && (
<> <DisplayModeMenu
<div className="fixed inset-0 z-[12000]" onClick={() => setOpen(false)} /> autoMode={autoMode}
<div currentMode={currentMode}
ref={positionMenu} onClose={() => setOpen(false)}
className="fixed z-[12001] min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md" onSelect={handleSelect}
data-testid="display-mode-menu" triggerRef={triggerRef}
> />
{DISPLAY_MODE_OPTIONS.map(opt => {
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
return (
<button
key={opt.value}
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}`}
>
<span className="w-3 text-center text-[10px]">
{currentMode === opt.value ? '\u2713' : ''}
</span>
<OptIcon className="size-3.5 text-muted-foreground" />
{opt.label}
{opt.value === autoMode && (
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
)}
</button>
)
})}
</div>
</>,
document.body
)} )}
</div> </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
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) => (
<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
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={() => onSelect(option.value)}
data-testid={`display-mode-option-${option.value}`}
>
<span className="w-3 text-center text-[10px]">
{currentMode === option.value ? '\u2713' : ''}
</span>
<OptIcon className="size-3.5 text-muted-foreground" />
{option.label}
{option.value === autoMode && (
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
)}
</button>
)
}
function toBooleanValue(value: FrontmatterValue): boolean { function toBooleanValue(value: FrontmatterValue): boolean {
if (typeof value === 'boolean') return value if (typeof value === 'boolean') return value
if (typeof value === 'string') return value.toLowerCase() === 'true' if (typeof value === 'string') return value.toLowerCase() === 'true'

View File

@@ -133,7 +133,9 @@ describe('PulseView', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0) 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(() => { await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument() expect(screen.getByText('my project')).toBeInTheDocument()
@@ -164,7 +166,9 @@ describe('PulseView', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0) 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(() => { await waitFor(() => {
expect(screen.getByText(fileText)).toBeInTheDocument() expect(screen.getByText(fileText)).toBeInTheDocument()

View File

@@ -152,59 +152,53 @@ function CommitCard({
return ( return (
<div className="border-b border-border px-4 py-2"> <div className="border-b border-border px-4 py-2">
<div <div className="flex items-start" style={{ gap: 8 }}>
className={cn(
'flex cursor-pointer items-start justify-between py-2 transition-colors focus-visible:bg-accent/40',
PULSE_ROW_FOCUS_CLASS_NAME,
PULSE_EDGE_TO_EDGE_ROW_CLASS_NAME,
expanded ? 'bg-accent/40' : 'hover: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)
}}
>
<div className="min-w-0 flex-1">
<div className="flex items-center" style={{ gap: 6, marginBottom: 2 }}>
<GitCommit size={13} className="text-muted-foreground" style={{ flexShrink: 0 }} />
<span className="truncate text-[13px] font-medium text-foreground">{commit.message}</span>
</div>
<div className="flex items-center" style={{ gap: 8 }}>
<span className="text-[11px] text-muted-foreground">{relativeDate(commit.date)}</span>
{commitUrl ? (
<a
className="flex 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')}
>
{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 <button
type="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={cn(
'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 focus-visible:bg-accent/40',
)}
style={{ gap: 8 }}
onClick={toggleExpanded}
onKeyDown={(event) => handleActivationKey(event, toggleExpanded)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center" style={{ gap: 6, marginBottom: 2 }}>
<GitCommit size={13} className="text-muted-foreground" style={{ flexShrink: 0 }} />
<span className="truncate text-[13px] font-medium text-foreground">{commit.message}</span>
</div>
<div className="flex items-center" style={{ gap: 8 }}>
<span className="text-[11px] text-muted-foreground">{relativeDate(commit.date)}</span>
{!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="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()
void openExternalUrl(commitUrl)
}}
title={translate(locale, 'pulse.openOnGitHub')}
>
{commit.shortHash}
<ArrowSquareOut size={10} />
</a>
)}
<button
type="button"
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 }} style={{ width: 20, height: 20 }}
onClick={(event) => { onClick={toggleExpanded}
event.stopPropagation()
toggleExpanded()
}}
aria-label={translate(locale, expanded ? 'pulse.collapseFiles' : 'pulse.expandFiles')} aria-label={translate(locale, expanded ? 'pulse.collapseFiles' : 'pulse.expandFiles')}
> >
<Chevron size={12} /> <Chevron size={12} />
@@ -233,14 +227,13 @@ function DayGroup({ label, commits, locale, onOpenNote }: {
return ( return (
<div> <div>
<div <button
type="button"
className={cn( 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, PULSE_ROW_FOCUS_CLASS_NAME,
)} )}
style={{ padding: '6px 16px', gap: 6 }} style={{ padding: '6px 16px', gap: 6 }}
role="button"
tabIndex={0}
aria-expanded={!collapsed} aria-expanded={!collapsed}
onClick={toggleCollapsed} onClick={toggleCollapsed}
onKeyDown={(event) => handleActivationKey(event, 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'), label: translate(locale, commits.length === 1 ? 'pulse.commitSingular' : 'pulse.commitPlural'),
})}) })})
</span> </span>
</div> </button>
{!collapsed && commits.map((commit) => ( {!collapsed && commits.map((commit) => (
<CommitCard key={commit.hash} commit={commit} locale={locale} onOpenNote={onOpenNote} /> <CommitCard key={commit.hash} commit={commit} locale={locale} onOpenNote={onOpenNote} />
))} ))}
@@ -268,13 +261,27 @@ function PulseHeader({
onExpandSidebar, onExpandSidebar,
locale = 'en', locale = 'en',
}: Pick<PulseViewProps, 'sidebarCollapsed' | 'onExpandSidebar' | 'locale'>) { }: 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 ( return (
<div <div
ref={headerRef}
className="flex shrink-0 items-center justify-between border-b border-border" className="flex shrink-0 items-center justify-between border-b border-border"
style={{ height: 52, padding: '0 16px', cursor: 'default' }} style={{ height: 52, padding: '0 16px', cursor: 'default' }}
onMouseDown={onMouseDown}
data-testid="pulse-header" data-testid="pulse-header"
> >
<div className="flex items-center" style={{ gap: 8 }}> <div className="flex items-center" style={{ gap: 8 }}>
@@ -333,7 +340,7 @@ function ErrorState({ message, locale = 'en', onRetry }: { message: string; loca
return ( return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}> <div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<p className="text-[13px]">{message}</p> <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" 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} onClick={onRetry}
> >

View File

@@ -111,6 +111,7 @@ function useQuickOpenKeyboard({
export function QuickOpenPalette({ open, entries, isLoading = false, onSelect, onCreateNote, onClose, locale = 'en' }: QuickOpenPaletteProps) { export function QuickOpenPalette({ open, entries, isLoading = false, onSelect, onCreateNote, onClose, locale = 'en' }: QuickOpenPaletteProps) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const rootRef = useRef<HTMLDivElement>(null)
const { results, selectedIndex, setSelectedIndex, handleKeyDown } = useNoteSearch(entries, query) const { results, selectedIndex, setSelectedIndex, handleKeyDown } = useNoteSearch(entries, query)
const createAction = useQuickOpenCreateAction({ query, isLoading, resultCount: results.length, onCreateNote, onClose }) 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 }) 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 if (!open) return null
return ( return (
<div <div
ref={rootRef}
data-testid="quick-open-palette" data-testid="quick-open-palette"
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]" 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 <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)]" 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)]"
onClick={(e) => e.stopPropagation()}
> >
<Input <Input
ref={inputRef} ref={inputRef}

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,13 +21,13 @@ export function RenameDetectedBanner({ renames, onUpdate, onDismiss }: RenameDet
<span className="flex-1 text-foreground"> <span className="flex-1 text-foreground">
{count} file{count !== 1 ? 's' : ''} renamed outside Tolaria. Update wikilinks? {count} file{count !== 1 ? 's' : ''} renamed outside Tolaria. Update wikilinks?
</span> </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" 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} onClick={onUpdate}
> >
Update wikilinks Update wikilinks
</button> </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" 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} onClick={onDismiss}
> >

View File

@@ -5,13 +5,14 @@ interface ResizeHandleProps {
} }
export function ResizeHandle({ onResize }: ResizeHandleProps) { export function ResizeHandle({ onResize }: ResizeHandleProps) {
const handleRef = useRef<HTMLDivElement>(null)
const isDragging = useRef(false) const isDragging = useRef(false)
const lastX = useRef(0) const lastX = useRef(0)
const pendingDelta = useRef(0) const pendingDelta = useRef(0)
const rafId = useRef(0) const rafId = useRef(0)
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
(e: React.MouseEvent) => { (e: MouseEvent) => {
e.preventDefault() e.preventDefault()
isDragging.current = true isDragging.current = true
lastX.current = e.clientX lastX.current = e.clientX
@@ -65,10 +66,17 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
} }
}, [onResize]) }, [onResize])
useEffect(() => {
const handle = handleRef.current
if (!handle) return
handle.addEventListener('mousedown', handleMouseDown)
return () => handle.removeEventListener('mousedown', handleMouseDown)
}, [handleMouseDown])
return ( return (
<div <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)]" 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, onClose,
}: SearchPanelProps) { }: SearchPanelProps) {
const dateDisplayFormat = useDateDisplayFormat() const dateDisplayFormat = useDateDisplayFormat()
const rootRef = useRef<HTMLDivElement>(null)
const { const {
elapsedMs, elapsedMs,
entryLookup, entryLookup,
@@ -201,16 +202,34 @@ export function SearchPanel({
typeEntryMap, typeEntryMap,
} = useSearchPanelController({ open, vaultPath, entries, onSelectNote, onClose }) } = 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 if (!open) return null
return ( return (
<div <div
ref={rootRef}
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]" 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 <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)]" 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)]"
onClick={e => e.stopPropagation()}
> >
<SearchInput <SearchInput
ref={inputRef} ref={inputRef}
@@ -249,7 +268,7 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
function SearchInput({ query, loading, onChange, onKeyDown }, ref) { function SearchInput({ query, loading, onChange, onKeyDown }, ref) {
return ( return (
<div className="flex items-center gap-3 border-b border-border px-4 py-3"> <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" /> <circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" /> <path d="m21 21-4.35-4.35" />
</svg> </svg>
@@ -264,6 +283,7 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
/> />
{loading && ( {loading && (
<svg <svg
aria-hidden="true"
className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" className="h-4 w-4 shrink-0 animate-spin text-muted-foreground"
data-testid="search-spinner" data-testid="search-spinner"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -361,9 +381,10 @@ function SearchResultRow({
}) })
return ( return (
<div <button
type="button"
className={cn( 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", selected ? "bg-accent" : "hover:bg-secondary",
)} )}
onClick={() => onSelect(result)} onClick={() => onSelect(result)}
@@ -381,7 +402,7 @@ function SearchResultRow({
<WorkspaceInitialsBadge workspace={presentation.workspace} testId="search-result-workspace-badge" /> <WorkspaceInitialsBadge workspace={presentation.workspace} testId="search-result-workspace-badge" />
</div> </div>
<SearchResultSubtitle subtitle={presentation.subtitle} /> <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 { Input } from './ui/input'
import { import {
Select, Select,
@@ -107,6 +107,7 @@ export function SettingsRow({
} }
export function SelectControl({ export function SelectControl({
id,
value, value,
onValueChange, onValueChange,
options, options,
@@ -114,6 +115,7 @@ export function SelectControl({
ariaLabel, ariaLabel,
autoFocus = false, autoFocus = false,
}: { }: {
id?: string
value: string value: string
onValueChange: (value: string) => void onValueChange: (value: string) => void
options: SelectOption[] options: SelectOption[]
@@ -124,6 +126,7 @@ export function SelectControl({
return ( return (
<Select value={value} onValueChange={onValueChange}> <Select value={value} onValueChange={onValueChange}>
<SelectTrigger <SelectTrigger
id={id}
className="w-full bg-transparent" className="w-full bg-transparent"
aria-label={ariaLabel} aria-label={ariaLabel}
data-testid={testId} data-testid={testId}
@@ -173,17 +176,19 @@ export function NumberInputControl({
} }
export function SettingsSwitchControl({ export function SettingsSwitchControl({
id,
label, label,
checked, checked,
onChange, onChange,
disabled = false, disabled = false,
}: { }: {
id?: string
label: string label: string
checked: boolean checked: boolean
onChange: (value: boolean) => void onChange: (value: boolean) => void
disabled?: boolean 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({ export function LabeledSelect({
@@ -201,10 +206,13 @@ export function LabeledSelect({
testId: string testId: string
autoFocus?: boolean autoFocus?: boolean
}) { }) {
const triggerId = useId()
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <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 <SelectControl
id={triggerId}
value={value} value={value}
onValueChange={onValueChange} onValueChange={onValueChange}
options={options} options={options}
@@ -258,8 +266,12 @@ export function SettingsSwitchRow({
disabled?: boolean disabled?: boolean
testId?: string testId?: string
}) { }) {
const generatedId = useId()
const switchId = testId ?? generatedId
return ( return (
<label <label
htmlFor={switchId}
className={`${SETTINGS_GROUP_ITEM_CLASS} flex flex-col gap-3 lg:flex-row lg:items-center`} 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 }} style={{ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.6 : 1 }}
data-testid={testId} data-testid={testId}
@@ -269,7 +281,7 @@ export function SettingsSwitchRow({
<div className="text-xs leading-5 text-muted-foreground">{description}</div> <div className="text-xs leading-5 text-muted-foreground">{description}</div>
</div> </div>
<div className="flex justify-start lg:shrink-0 lg:justify-end"> <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> </div>
</label> </label>
) )

View File

@@ -20,8 +20,6 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import type { Settings } from '../types' import type { Settings } from '../types'
@@ -191,7 +189,7 @@ const DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS = 90
const DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS = 30 const DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS = 30
type Translate = ReturnType<typeof createTranslator> 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) return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
} }
@@ -355,6 +353,7 @@ function SettingsPanelInner({
onClose, onClose,
}: SettingsPanelInnerProps) { }: SettingsPanelInnerProps) {
const [draft, setDraft] = useState(() => createSettingsDraft(settings, explicitOrganizationEnabled)) const [draft, setDraft] = useState(() => createSettingsDraft(settings, explicitOrganizationEnabled))
const backdropRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null) const panelRef = useRef<HTMLDivElement>(null)
const draftLocale = resolveEffectiveLocale(draft.uiLanguage, [systemLocale]) const draftLocale = resolveEffectiveLocale(draft.uiLanguage, [systemLocale])
const t = createTranslator(draftLocale) const t = createTranslator(draftLocale)
@@ -406,12 +405,8 @@ function SettingsPanelInner({
onClose() onClose()
}, [draft, onClose, onSave, onSaveExplicitOrganization, settings]) }, [draft, onClose, onSave, onSaveExplicitOrganization, settings])
const handleBackdropClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => { useEffect(() => {
if (event.target === event.currentTarget) onClose() const handleKeyDown = (event: globalThis.KeyboardEvent) => {
}, [onClose])
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.stopPropagation() event.stopPropagation()
onClose() onClose()
@@ -422,21 +417,35 @@ function SettingsPanelInner({
event.preventDefault() event.preventDefault()
handleSave() 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 ( return (
<div <div
ref={backdropRef}
className="fixed inset-0 z-50 flex items-center justify-center" className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'var(--shadow-overlay)' }} style={{ background: 'var(--shadow-overlay)' }}
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
data-testid="settings-panel" data-testid="settings-panel"
> >
<SettingsBackdropCloseButton onClose={onClose} t={t} />
<div <div
ref={panelRef} 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' }} style={{ width: 'min(960px, calc(100vw - 48px))', maxHeight: '86vh', display: 'flex', flexDirection: 'column' }}
> >
<SettingsHeader onClose={onClose} t={t} /> <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 }) { function SettingsHeader({ onClose, t }: { onClose: () => void; t: Translate }) {
return ( return (
<div <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 type { SidebarSelection, VaultEntry, WorkspaceIdentity } from '../types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors' 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 }) { 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({ function NavItemCount({
@@ -224,8 +224,11 @@ function ClickableNavItem({
padding: ReturnType<typeof getNavItemPadding> padding: ReturnType<typeof getNavItemPadding>
}) { }) {
return ( return (
<div <Button
className={cn("flex cursor-pointer select-none items-center gap-2 rounded transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")} 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 }} style={{ padding, borderRadius: 4 }}
onClick={onClick} onClick={onClick}
> >
@@ -238,7 +241,7 @@ function ClickableNavItem({
style={resolveBadgeStyle(isActive, activeBadgeClassName, activeBadgeStyle, badgeStyle)} style={resolveBadgeStyle(isActive, activeBadgeClassName, activeBadgeStyle, badgeStyle)}
compact={compact} compact={compact}
/> />
</div> </Button>
) )
} }
@@ -455,8 +458,9 @@ function SectionHeaderLabel({
} }
return ( return (
<span <button
className="min-w-0 truncate text-[13px] font-medium" 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) }} style={{ marginLeft: 4, color: getSectionHeaderTitleColor(isActive, sectionColor) }}
onDoubleClick={(event) => { onDoubleClick={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -464,7 +468,7 @@ function SectionHeaderLabel({
}} }}
> >
{label} {label}
</span> </button>
) )
} }
@@ -497,14 +501,35 @@ function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, ite
onStartRename?: () => void; onSelectTypeNote?: () => void onStartRename?: () => void; onSelectTypeNote?: () => void
locale?: AppLocale 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 ( return (
<div <div
ref={headerRef}
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", !isActive && "hover:bg-accent")} 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) }} style={{ padding: SIDEBAR_ITEM_PADDING.withCount, borderRadius: 4, gap: 4, ...getSectionHeaderBackground(isActive, sectionLightColor) }}
{...dragHandleProps} {...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 }}> <div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
<Icon size={16} weight={getSectionHeaderIconWeight(isActive)} style={{ color: sectionColor, flexShrink: 0 }} /> <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 normalizedName = entry.name.toLowerCase()
const tokens = normalizedName.split(/[^a-z0-9]+/).filter(Boolean) const tokens = normalizedName.split(/[^a-z0-9]+/).filter(Boolean)
if (normalizedName === query) return 0 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 (tokens.some(token => token.startsWith(query))) return 2
if (normalizedName.startsWith(query)) return 3 if (normalizedName.startsWith(query)) return 3
return 4 return 4
@@ -1379,6 +1379,17 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
activatePlainTextPaste() activatePlainTextPaste()
handleWhitespaceMouseSelection(event) handleWhitespaceMouseSelection(event)
}, [activatePlainTextPaste, handleWhitespaceMouseSelection]) }, [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 insertWikilink = useInsertWikilink(editor, runEditorAction)
const suggestionMenuItems = useSuggestionMenuItems({ const suggestionMenuItems = useSuggestionMenuItems({
baseItems, baseItems,
@@ -1394,9 +1405,10 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
return ( return (
<div <div
ref={containerRef} ref={containerRef}
role="application"
aria-label="Rich text editor"
className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`}
style={cssVars as React.CSSProperties} style={cssVars as React.CSSProperties}
onClick={handleContainerClick}
onCopyCapture={handleEditorCopy} onCopyCapture={handleEditorCopy}
onFocusCapture={handleFocusCapture} onFocusCapture={handleFocusCapture}
onMouseLeave={clearCopyTarget} onMouseLeave={clearCopyTarget}

View File

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

View File

@@ -67,11 +67,11 @@ function TagOption({
<div <div
className="flex w-full items-center gap-1 px-2 py-1 transition-colors" className="flex w-full items-center gap-1 px-2 py-1 transition-colors"
style={{ borderRadius: 4, backgroundColor: highlighted ? 'var(--muted)' : 'transparent' }} 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" className="flex min-w-0 flex-1 items-center gap-1.5 border-none bg-transparent p-0 text-left"
onClick={() => onToggle(tag)} onClick={() => onToggle(tag)}
onMouseEnter={onMouseEnter}
data-testid={`tag-option-${tag}`} data-testid={`tag-option-${tag}`}
> >
<span className="w-3.5 text-center text-[10px]" style={{ color: style.color }}> <span className="w-3.5 text-center text-[10px]" style={{ color: style.color }}>
@@ -79,10 +79,11 @@ function TagOption({
</span> </span>
<TagPill tag={tag} /> <TagPill tag={tag} />
</button> </button>
<button <button type="button"
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0" className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0"
style={{ backgroundColor: style.color }} style={{ backgroundColor: style.color }}
onClick={() => onToggleColor(tag)} onClick={() => onToggleColor(tag)}
onMouseEnter={onMouseEnter}
title="Change color" title="Change color"
data-testid={`tag-color-swatch-${tag}`} data-testid={`tag-color-swatch-${tag}`}
/> />
@@ -241,7 +242,13 @@ export function TagsDropdown({
<span ref={anchorRef} data-testid="tags-dropdown"> <span ref={anchorRef} data-testid="tags-dropdown">
{createPortal( {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 <div
ref={dropdownRef} ref={dropdownRef}
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg" 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 ( return (
<> <>
{showDivider && <div className="my-1 h-px bg-border" />} {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" className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
style={{ style={{
borderRadius: 4, 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 { getAssetUrlsByImport } from '@tldraw/assets/imports.vite'
import { Dialog as DialogPrimitive } from 'radix-ui' import { Dialog as DialogPrimitive } from 'radix-ui'
import { import {
@@ -197,8 +197,12 @@ function installZoomAwareViewport(editor: Editor): () => void {
return () => { return () => {
window.removeEventListener('laputa-zoom-change', scheduleViewportUpdate) window.removeEventListener('laputa-zoom-change', scheduleViewportUpdate)
animationFrameIds.forEach((id) => window.cancelAnimationFrame(id)) animationFrameIds.forEach((id) => {
timeoutIds.forEach((id) => window.clearTimeout(id)) window.cancelAnimationFrame(id)
})
timeoutIds.forEach((id) => {
window.clearTimeout(id)
})
editor.updateViewportScreenBounds = updateViewportScreenBounds editor.updateViewportScreenBounds = updateViewportScreenBounds
editor.dispatch = dispatch editor.dispatch = dispatch
} }
@@ -241,12 +245,12 @@ function canDismissDialog(openedAt: number): boolean {
return performance.now() - openedAt >= DIALOG_OPEN_DISMISS_GRACE_MS 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 return event.target === event.currentTarget
} }
function shouldCloseFromOverlayClick( function shouldCloseFromOverlayClick(
event: ReactMouseEvent<HTMLDivElement>, event: { currentTarget: EventTarget | null; target: EventTarget | null },
dialog: TLUiDialog, dialog: TLUiDialog,
mouseDownInsideContent: boolean mouseDownInsideContent: boolean
): boolean { ): boolean {
@@ -291,6 +295,7 @@ function TolariaTldrawDialogContent({
} }
const TolariaTldrawDialog = memo(function TolariaTldrawDialog({ dialog, onClose }: TolariaTldrawDialogProps) { const TolariaTldrawDialog = memo(function TolariaTldrawDialog({ dialog, onClose }: TolariaTldrawDialogProps) {
const overlayRef = useRef<HTMLDivElement>(null)
const mouseDownInsideContentRef = useRef(false) const mouseDownInsideContentRef = useRef(false)
const { openedAtRef, readyToOpen } = useDeferredDialogOpen() const { openedAtRef, readyToOpen } = useDeferredDialogOpen()
@@ -302,22 +307,35 @@ const TolariaTldrawDialog = memo(function TolariaTldrawDialog({ dialog, onClose
const handleOpenChange = useCallback((isOpen: boolean) => { const handleOpenChange = useCallback((isOpen: boolean) => {
if (!isOpen) closeDialogNow() if (!isOpen) closeDialogNow()
}, [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 if (!readyToOpen) return null
return ( return (
<DialogPrimitive.Root open onOpenChange={handleOpenChange}> <DialogPrimitive.Root open onOpenChange={handleOpenChange}>
<div <div
ref={overlayRef}
dir="ltr" dir="ltr"
className="tlui-dialog__overlay" className="tlui-dialog__overlay"
onMouseDown={(event) => {
if (event.target === event.currentTarget) mouseDownInsideContentRef.current = false
}}
onClick={(event) => {
if (shouldCloseFromOverlayClick(event, dialog, mouseDownInsideContentRef.current)) {
closeDialogFromBackground()
}
}}
> >
<TolariaTldrawDialogContent <TolariaTldrawDialogContent
dialog={dialog} dialog={dialog}
@@ -436,7 +454,7 @@ export function TldrawWhiteboard({
} }
}, [boardId, store]) }, [boardId, store])
const startResize = (mode: ResizeMode) => (event: ReactPointerEvent<HTMLDivElement>) => { const startResize = (mode: ResizeMode) => (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -489,23 +507,23 @@ export function TldrawWhiteboard({
store={store} store={store}
user={tldrawUser} user={tldrawUser}
/> />
<div <button
type="button"
aria-label="Resize whiteboard width" 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')} onPointerDown={startResize('width')}
role="separator"
/> />
<div <button
type="button"
aria-label="Resize whiteboard height" 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')} onPointerDown={startResize('height')}
role="separator"
/> />
<div <button
type="button"
aria-label="Resize whiteboard" 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')} onPointerDown={startResize('both')}
role="separator"
/> />
</div> </div>
) )

View File

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

View File

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

View File

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

View File

@@ -359,7 +359,7 @@ describe('WikilinkChatInput', () => {
render( render(
<> <>
<Controlled onDraftChange={onDraftChange} /> <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 const initialEditor = screen.getByTestId('agent-input') as HTMLDivElement

View File

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

View File

@@ -103,7 +103,7 @@ function DiffModeView({ diffContent, locale = 'en', onToggleDiff }: { diffConten
return ( return (
<div className="flex-1 overflow-auto"> <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" 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} onClick={onToggleDiff}
title={label} title={label}

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ function BacklinkEntry({ entry, context, onNavigate }: {
}) { }) {
const isDimmed = entry.archived const isDimmed = entry.archived
return ( 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" 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)} onClick={() => onNavigate(entry.title)}
title={entryStatusTitle(entry)} title={entryStatusTitle(entry)}

View File

@@ -23,7 +23,7 @@ export function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCom
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{commits.map((c) => ( {commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}> <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" 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)} onClick={() => onViewCommitDiff?.(c.hash)}
title={`View diff for ${c.shortHash}`} title={`View diff for ${c.shortHash}`}

View File

@@ -32,7 +32,7 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
onToggle: () => void onToggle: () => void
onOpenRawEditor?: () => void onOpenRawEditor?: () => void
}) { }) {
const { onMouseDown } = useDragRegion() const { dragRegionRef } = useDragRegion<HTMLDivElement>()
const propertiesTitle = translate(locale, 'inspector.title.properties') const propertiesTitle = translate(locale, 'inspector.title.properties')
const showWarnings = Boolean(frontmatterWarnings && hasFrontmatterWarnings(frontmatterWarnings) && onOpenRawEditor) const showWarnings = Boolean(frontmatterWarnings && hasFrontmatterWarnings(frontmatterWarnings) && onOpenRawEditor)
const propertiesIcon = (testId?: string) => ( const propertiesIcon = (testId?: string) => (
@@ -48,12 +48,12 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
return ( return (
<div <div
ref={dragRegionRef}
className="flex shrink-0 items-center border-b border-border" className="flex shrink-0 items-center border-b border-border"
style={{ height: 52, padding: '6px 12px', gap: 8, cursor: 'default' }} style={{ height: 52, padding: '6px 12px', gap: 8, cursor: 'default' }}
onMouseDown={onMouseDown}
> >
{collapsed ? ( {collapsed ? (
<button <button type="button"
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle} onClick={onToggle}
title={toggleLabel} title={toggleLabel}
@@ -63,7 +63,7 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
</button> </button>
) : ( ) : (
<> <>
<button <button type="button"
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle} onClick={onToggle}
title={toggleLabel} title={toggleLabel}
@@ -76,7 +76,7 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
<FrontmatterWarningsButton locale={locale} onOpenRawEditor={onOpenRawEditor} /> <FrontmatterWarningsButton locale={locale} onOpenRawEditor={onOpenRawEditor} />
)} )}
<span className="flex-1" /> <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" className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle} onClick={onToggle}
title={toggleLabel} 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"> <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" /> <Sparkle size={24} className="text-muted-foreground" />
<p className="m-0 text-center text-[13px] text-muted-foreground">{translate(locale, 'inspector.empty.noProperties')}</p> <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" 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} 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"> <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" /> <WarningCircle size={24} className="text-destructive" />
<p className="m-0 text-center text-[13px] text-muted-foreground">{translate(locale, 'inspector.empty.invalidProperties')}</p> <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" 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} onClick={onFix}
> >

View File

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

View File

@@ -55,7 +55,7 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
return ( return (
<div key={label} className="flex min-w-0 flex-col gap-1 px-1.5"> <div key={label} className="flex min-w-0 flex-col gap-1 px-1.5">
<ActionTooltip copy={{ label: tooltip }} side="top" align="start"> <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} {label}
</span> </span>
</ActionTooltip> </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 type { VaultEntry } from '../../types'
import { Plus, X } from '@phosphor-icons/react' import { Plus, X } from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../../utils/frontmatter' import type { ParsedFrontmatter } from '../../utils/frontmatter'
@@ -234,8 +234,9 @@ function CreateAndOpenOption({ title, selected, locale, onClick, onHover }: {
onHover: () => void onHover: () => void
}) { }) {
return ( return (
<div <button
className={`flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm transition-colors ${selected ? 'bg-accent' : 'hover:bg-secondary'}`} 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" data-testid="create-and-open-option"
onMouseDown={e => e.preventDefault()} onMouseDown={e => e.preventDefault()}
onClick={onClick} onClick={onClick}
@@ -245,7 +246,7 @@ function CreateAndOpenOption({ title, selected, locale, onClick, onHover }: {
<span className="truncate text-foreground"> <span className="truncate text-foreground">
{translate(locale, 'inspector.relationship.createAndOpen')} <strong>{title}</strong> {translate(locale, 'inspector.relationship.createAndOpen')} <strong>{title}</strong>
</span> </span>
</div> </button>
) )
} }
@@ -398,9 +399,13 @@ function InlineAddNote({ entries, sourceEntry, vaultPath, locale, onAdd, onCreat
handleCreateAndOpen, handleCreateAndOpen,
} = useInlineAddNoteState(entries, vaultPath, sourceEntry, onAdd, onCreateAndOpenNote) } = useInlineAddNoteState(entries, vaultPath, sourceEntry, onAdd, onCreateAndOpenNote)
useEffect(() => {
if (active) inputRef.current?.focus()
}, [active, inputRef])
if (!active) { if (!active) {
return ( 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" 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 }} style={{ borderRadius: 6, padding: '6px 10px', fontSize: 12 }}
onClick={() => setActive(true)} onClick={() => setActive(true)}
@@ -416,7 +421,6 @@ function InlineAddNote({ entries, sourceEntry, vaultPath, locale, onAdd, onCreat
<div className="group/add relative flex items-center"> <div className="group/add relative flex items-center">
<input <input
ref={inputRef} ref={inputRef}
autoFocus
className="w-full border border-border bg-transparent text-foreground" className="w-full border border-border bg-transparent text-foreground"
style={{ borderRadius: 6, outline: 'none', minWidth: 0, padding: '6px 10px', fontSize: 12 }} style={{ borderRadius: 6, outline: 'none', minWidth: 0, padding: '6px 10px', fontSize: 12 }}
placeholder={translate(locale, 'inspector.relationship.noteTitle')} placeholder={translate(locale, 'inspector.relationship.noteTitle')}
@@ -425,7 +429,7 @@ function InlineAddNote({ entries, sourceEntry, vaultPath, locale, onAdd, onCreat
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
data-testid="add-relation-ref-input" 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" 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} onClick={dismiss}
> >
@@ -459,11 +463,11 @@ function RelationshipGroup({ label, refs, entries, sourceEntry, typeEntryMap, va
return ( return (
<RelationshipSectionRow label={label} locale={locale}> <RelationshipSectionRow label={label} locale={locale}>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{refs.map((ref, idx) => { {refs.map((ref) => {
const props = resolveRefProps(ref, entries, typeEntryMap) const props = resolveRefProps(ref, entries, typeEntryMap)
return ( return (
<LinkButton <LinkButton
key={`${ref}-${idx}`} key={`${ref}-${props.target}`}
{...props} {...props}
onClick={() => onNavigate(props.target)} onClick={() => onNavigate(props.target)}
onRemove={onRemoveRef ? () => onRemoveRef(ref) : undefined} onRemove={onRemoveRef ? () => onRemoveRef(ref) : undefined}
@@ -879,7 +883,7 @@ function updateRefsForAddition(refs: string[], refToAdd: string): FrontmatterVal
function DisabledLinkButton({ locale }: { locale: AppLocale }) { function DisabledLinkButton({ locale }: { locale: AppLocale }) {
return ( 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 { createElement, useMemo, useState, type ComponentType, type MouseEvent, type ReactNode, type SVGAttributes } from 'react'
import { Link } from '@phosphor-icons/react' import { Link } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
import type { VaultEntry } from '../../types' import type { VaultEntry } from '../../types'
import { useDateDisplayFormat } from '../../hooks/useAppPreferences' import { useDateDisplayFormat } from '../../hooks/useAppPreferences'
import { resolveNoteIcon } from '../../utils/noteIcon' import { resolveNoteIcon } from '../../utils/noteIcon'
@@ -119,21 +118,43 @@ export function PropertyChips({
return ( return (
<div className="mt-1 flex flex-wrap gap-1" data-testid="property-chips"> <div className="mt-1 flex flex-wrap gap-1" data-testid="property-chips">
{chips.map(({ key, values }) => {chips.map(({ key, values }) =>
values.map((chip, index) => ( values.map((chip, index) => {
<span const content = (
key={`${key}-${index}`} <>
className={cn( <PropertyChipIcon noteIcon={chip.noteIcon} typeIcon={chip.typeIcon} tone={chip.tone} />
'inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground', <span className="truncate whitespace-nowrap">{chip.label}</span>
chip.action && 'cursor-pointer', </>
)} )
style={chip.style} const chipKey = `${key}-${chip.tone}-${chip.label}`
onClick={(event) => { void handleChipClick(event, chip, onOpenNote) }} const testId = toChipTestId(key, index)
data-testid={toChipTestId(key, index)} if (!chip.action) {
> return (
<PropertyChipIcon noteIcon={chip.noteIcon} typeIcon={chip.typeIcon} tone={chip.tone} /> <span
<span className="truncate whitespace-nowrap">{chip.label}</span> key={chipKey}
</span> 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> </div>
) )

View File

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

View File

@@ -93,12 +93,22 @@ function HeaderTitle({
}: Pick<NoteListHeaderProps, 'title' | 'typeDocument' | 'onOpenType'>) { }: Pick<NoteListHeaderProps, 'title' | 'typeDocument' | 'onOpenType'>) {
const handleClick = typeDocument ? () => onOpenType(typeDocument) : undefined 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 ( return (
<h3 <h3
className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold" 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} {title}
</h3> </h3>
@@ -291,14 +301,14 @@ export function NoteListHeader({
onSearchKeyDown, onSearchKeyDown,
onGitRepositoryChange, onGitRepositoryChange,
}: NoteListHeaderProps) { }: NoteListHeaderProps) {
const { onMouseDown: onDragMouseDown } = useDragRegion() const { dragRegionRef } = useDragRegion<HTMLDivElement>()
const collapsedSidebarPadding = sidebarCollapsed && isMac() const collapsedSidebarPadding = sidebarCollapsed && isMac()
? COLLAPSED_SIDEBAR_MAC_CHROME_PADDING ? COLLAPSED_SIDEBAR_MAC_CHROME_PADDING
: undefined : undefined
return ( 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 <HeaderLeading
title={title} title={title}
typeDocument={typeDocument} typeDocument={typeDocument}

View File

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

View File

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

View File

@@ -1207,8 +1207,10 @@ export function useNoteListInteractions({
}) })
useEffect(() => { useEffect(() => {
void noteListFilter
void selection
multiSelect.clear() 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({ const handleClickNote = useNoteClickHandler({
isChangesView, isChangesView,

View File

@@ -152,7 +152,7 @@ function releaseRecoveryState(
const state = Reflect.get(view, DISPATCH_RECOVERY_STATE_KEY) const state = Reflect.get(view, DISPATCH_RECOVERY_STATE_KEY)
if (!isDispatchRecoveryState(state) || state.originalDispatch !== originalDispatch) return 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 state.refCount -= 1
if (state.refCount > 0) return if (state.refCount > 0) return

View File

@@ -62,18 +62,19 @@ function SortableFavoriteItem({
{...attributes} {...attributes}
{...listeners} {...listeners}
> >
<div <button
type="button"
className={`group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors ${isActive ? '' : 'hover:bg-accent'}`} 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 } : {}) }} style={{ padding: SIDEBAR_ITEM_PADDING.withCount, borderRadius: 4, gap: 4, ...(isActive ? { background: typeLightColor } : {}) }}
onClick={onSelect} onClick={onSelect}
> >
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}> <div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
<NoteTitleIcon icon={icon} size={16} color={typeColor} /> <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} {entry.title}
</span> </span>
</div> </div>
</div> </button>
</div> </div>
) )
} }

View File

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

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