fix: resolve codacy high severity findings
This commit is contained in:
@@ -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
38
biome.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
13
index.html
13
index.html
@@ -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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -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
79
pnpm-lock.yaml
generated
@@ -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
3
src-tauri/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")));
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/App.tsx
27
src/App.tsx
@@ -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: '' }
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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')}>×</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')}>×</button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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: '' }]}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}</>,
|
||||||
|
|||||||
@@ -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))}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user