From 2fcd808f092907f32ed0832ce76477438dbedca1 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 29 Apr 2026 03:53:54 +0200 Subject: [PATCH 1/3] fix: detect Windows npm ai agent shims From ed52a9e800fb9f902976e6ee2673bc140ed65bfb Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 29 Apr 2026 03:56:44 +0200 Subject: [PATCH 2/3] fix: preserve frontmatter date picker days --- package.json | 2 +- src/utils/propertyTypes.test.ts | 17 +++ src/utils/propertyTypes.ts | 125 +++++++++++++------- tests/smoke/frontmatter-date-picker.spec.ts | 61 ++++++++++ 4 files changed, 159 insertions(+), 46 deletions(-) create mode 100644 tests/smoke/frontmatter-date-picker.spec.ts diff --git a/package.json b/package.json index b547029f..a4d9ad68 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts", + "playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts", "playwright:regression": "playwright test tests/smoke/", "playwright:integration": "playwright test --config playwright.integration.config.ts", "test:coverage": "node scripts/run-vitest-coverage.mjs", diff --git a/src/utils/propertyTypes.test.ts b/src/utils/propertyTypes.test.ts index 1f37dced..362d302f 100644 --- a/src/utils/propertyTypes.test.ts +++ b/src/utils/propertyTypes.test.ts @@ -23,6 +23,17 @@ const localStorageMock = (() => { })() Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) +function withTimezone(timezone: string, fn: () => T): T { + const previousTimezone = process.env.TZ + process.env.TZ = timezone + try { + return fn() + } finally { + if (previousTimezone === undefined) delete process.env.TZ + else process.env.TZ = previousTimezone + } +} + describe('detectPropertyType', () => { it('detects boolean from value type', () => { expect(detectPropertyType('archived', true)).toBe('boolean') @@ -156,6 +167,12 @@ describe('toISODate', () => { expect(toISODate('2026-02-25T10:00:00')).toBe('2026-02-25') }) + it('keeps local ISO datetime dates stable in positive-offset timezones', () => { + withTimezone('Europe/Rome', () => { + expect(toISODate('2026-04-29T00:00:00')).toBe('2026-04-29') + }) + }) + it('returns original value for non-date strings', () => { expect(toISODate('not a date')).toBe('not a date') }) diff --git a/src/utils/propertyTypes.ts b/src/utils/propertyTypes.ts index 9b090a67..1792fdca 100644 --- a/src/utils/propertyTypes.ts +++ b/src/utils/propertyTypes.ts @@ -6,52 +6,56 @@ import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette, Hash } from import { canonicalSystemMetadataKey } from './systemMetadata' export type PropertyDisplayMode = 'text' | 'number' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color' +type PropertyKey = string +type PropertyValueText = string +type PropertyKeyPatterns = readonly PropertyKey[] +type DisplayModeOverrides = Record -const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/ -const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/ +const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})(T\d{2}:\d{2}(:\d{2})?)?/ +const COMMON_DATE_RE = /^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/ -const STATUS_VALUES = new Set([ +const STATUS_VALUES = new Set([ 'active', 'done', 'paused', 'archived', 'dropped', 'open', 'closed', 'not started', 'draft', 'mixed', 'published', 'in progress', 'blocked', 'cancelled', 'pending', ]) -const STATUS_KEY_PATTERNS = ['status'] -const DATE_KEY_PATTERNS = ['date', 'deadline', 'due', 'start', 'end', 'scheduled'] -const TAGS_KEY_PATTERNS = ['tags', 'keywords', 'categories', 'labels'] +const STATUS_KEY_PATTERNS: PropertyKeyPatterns = ['status'] +const DATE_KEY_PATTERNS: PropertyKeyPatterns = ['date', 'deadline', 'due', 'start', 'end', 'scheduled'] +const TAGS_KEY_PATTERNS: PropertyKeyPatterns = ['tags', 'keywords', 'categories', 'labels'] -function isIconKey(key: string): boolean { +function isIconKey(key: PropertyKey): boolean { return canonicalSystemMetadataKey(key) === '_icon' } -function keyMatchesPatterns(key: string, patterns: string[]): boolean { +function keyMatchesPatterns(key: PropertyKey, patterns: PropertyKeyPatterns): boolean { const lower = key.toLowerCase() return patterns.some(p => lower === p || lower.includes(p)) } -function isDateString(value: string): boolean { +function isDateString(value: PropertyValueText): boolean { return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value) } -function isStatusKey(key: string): boolean { +function isStatusKey(key: PropertyKey): boolean { return keyMatchesPatterns(key, STATUS_KEY_PATTERNS) } -function isDateKey(key: string): boolean { +function isDateKey(key: PropertyKey): boolean { return keyMatchesPatterns(key, DATE_KEY_PATTERNS) } -function isStatusString(key: string, value: string): boolean { +function isStatusString(key: PropertyKey, value: PropertyValueText): boolean { if (isStatusKey(key)) return true if (isDateKey(key)) return false return STATUS_VALUES.has(value.toLowerCase()) } -function isColorString(key: string, value: string): boolean { +function isColorString(key: PropertyKey, value: PropertyValueText): boolean { return isValidCssColor(value) && (value.startsWith('#') || isColorKeyName(key)) } -function detectStringType(key: string, strValue: string): PropertyDisplayMode { +function detectStringType(key: PropertyKey, strValue: PropertyValueText): PropertyDisplayMode { if (isIconKey(key)) return 'text' if (isStatusString(key, strValue)) return 'status' if (isDateString(strValue)) return 'date' @@ -59,7 +63,7 @@ function detectStringType(key: string, strValue: string): PropertyDisplayMode { return 'text' } -export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode { +export function detectPropertyType(key: PropertyKey, value: FrontmatterValue): PropertyDisplayMode { if (value === null || value === undefined) return 'text' if (typeof value === 'number') return 'number' if (typeof value === 'boolean') return 'boolean' @@ -69,14 +73,14 @@ export function detectPropertyType(key: string, value: FrontmatterValue): Proper return detectStringType(key, String(value)) } -let vaultOverrides: Record | null = null +let vaultOverrides: DisplayModeOverrides | null = null /** Initialize display mode overrides from vault config (replaces localStorage). */ -export function initDisplayModeOverrides(overrides: Record): void { - vaultOverrides = overrides as Record +export function initDisplayModeOverrides(overrides: Record): void { + vaultOverrides = overrides as DisplayModeOverrides } -export function loadDisplayModeOverrides(): Record { +export function loadDisplayModeOverrides(): DisplayModeOverrides { if (vaultOverrides !== null) return { ...vaultOverrides } const raw = getAppStorageItem('propertyModes') if (!raw) return {} @@ -87,60 +91,91 @@ export function loadDisplayModeOverrides(): Record } } -function persistDisplayModeOverrides(overrides: Record): void { +function persistDisplayModeOverrides(overrides: DisplayModeOverrides): void { vaultOverrides = { ...overrides } const snapshot = Object.keys(overrides).length > 0 ? { ...overrides } : null - updateVaultConfigField('property_display_modes', snapshot as Record | null) + updateVaultConfigField('property_display_modes', snapshot as Record | null) } -export function saveDisplayModeOverride(propertyName: string, mode: PropertyDisplayMode): void { +export function saveDisplayModeOverride(propertyName: PropertyKey, mode: PropertyDisplayMode): void { const overrides = loadDisplayModeOverrides() overrides[propertyName] = mode persistDisplayModeOverrides(overrides) } -export function removeDisplayModeOverride(propertyName: string): void { +export function removeDisplayModeOverride(propertyName: PropertyKey): void { const overrides = loadDisplayModeOverrides() delete overrides[propertyName] persistDisplayModeOverrides(overrides) } export function getEffectiveDisplayMode( - key: string, + key: PropertyKey, value: FrontmatterValue, - overrides: Record, + overrides: DisplayModeOverrides, ): PropertyDisplayMode { return overrides[key] ?? detectPropertyType(key, value) } -function resolveDateFromValue(value: string): Date | null { - const isoMatch = value.match(ISO_DATE_RE) - if (isoMatch) { - const date = new Date(isoMatch[0]) - return Number.isNaN(date.getTime()) ? null : date - } - - const parts = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/) - if (!parts) return null - - const date = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2])) - return Number.isNaN(date.getTime()) ? null : date +interface DateParts { + year: number + month: number + day: number } -export function formatDateValue(value: string): string { +function validDateParts(parts: DateParts): DateParts | null { + const date = dateFromParts(parts) + return date.getFullYear() === parts.year && date.getMonth() === parts.month - 1 && date.getDate() === parts.day + ? parts + : null +} + +function parseISODateParts(value: PropertyValueText): DateParts | null { + const match = value.match(ISO_DATE_RE) + if (!match) return null + return validDateParts({ + year: Number(match[1]), + month: Number(match[2]), + day: Number(match[3]), + }) +} + +function parseCommonDateParts(value: PropertyValueText): DateParts | null { + const match = value.match(COMMON_DATE_RE) + if (!match) return null + return validDateParts({ + year: Number(match[3]), + month: Number(match[1]), + day: Number(match[2]), + }) +} + +function dateFromParts(parts: DateParts): Date { + return new Date(parts.year, parts.month - 1, parts.day) +} + +function formatISODateParts(parts: DateParts): string { + const yyyy = String(parts.year).padStart(4, '0') + const mm = String(parts.month).padStart(2, '0') + const dd = String(parts.day).padStart(2, '0') + return `${yyyy}-${mm}-${dd}` +} + +function resolveDateFromValue(value: PropertyValueText): Date | null { + const parts = parseISODateParts(value) ?? parseCommonDateParts(value) + return parts ? dateFromParts(parts) : null +} + +export function formatDateValue(value: PropertyValueText): PropertyValueText { const date = resolveDateFromValue(value) return date ? date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : value } -export function toISODate(value: string): string { - const isoMatch = value.match(ISO_DATE_RE) - if (isoMatch) { - const d = new Date(isoMatch[0]) - if (!isNaN(d.getTime())) return d.toISOString().split('T')[0] - } - return value +export function toISODate(value: PropertyValueText): PropertyValueText { + const parts = parseISODateParts(value) + return parts ? formatISODateParts(parts) : value } export const DISPLAY_MODE_ICONS: Record = { diff --git a/tests/smoke/frontmatter-date-picker.spec.ts b/tests/smoke/frontmatter-date-picker.spec.ts new file mode 100644 index 00000000..c9bbf04e --- /dev/null +++ b/tests/smoke/frontmatter-date-picker.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, type Locator, type Page } from '@playwright/test' +import fs from 'fs' +import path from 'path' +import { + createFixtureVaultCopy, + openFixtureVaultDesktopHarness, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' +import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog' +import { triggerShortcutCommand } from './testBridge' + +test.use({ timezoneId: 'Europe/Rome' }) + +let tempVaultDir: string + +function alphaProjectPath(vaultPath: string): string { + return path.join(vaultPath, 'project', 'alpha-project.md') +} + +function seedDateProperty(notePath: string, value: string): void { + const content = fs.readFileSync(notePath, 'utf8') + fs.writeFileSync(notePath, content.replace('Status: Active\n', `Status: Active\nDate: ${value}\n`)) +} + +async function calendarDay(page: Page, year: number, monthIndex: number, day: number): Promise { + const dateLabel = await page.evaluate( + ({ y, m, d }) => new Date(y, m, d).toLocaleDateString(), + { y: year, m: monthIndex, d: day }, + ) + return page.locator(`button[data-day="${dateLabel}"]`).first() +} + +test.describe('Frontmatter date picker', () => { + test.beforeEach(async ({ page }) => { + tempVaultDir = createFixtureVaultCopy() + seedDateProperty(alphaProjectPath(tempVaultDir), '2026-04-29T00:00:00') + await openFixtureVaultDesktopHarness(page, tempVaultDir) + await page.setViewportSize({ width: 1600, height: 900 }) + }) + + test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) + }) + + test('local-midnight date properties keep the selected calendar day @smoke', async ({ page }) => { + const notePath = alphaProjectPath(tempVaultDir) + + await page.getByTestId('note-list-container').getByText('Alpha Project', { exact: true }).click() + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) + await triggerShortcutCommand(page, APP_COMMAND_IDS.viewToggleProperties) + await expect(page.getByTestId('add-property-row')).toBeVisible() + + const dateRow = page.getByTestId('editable-property').filter({ hasText: 'Date' }) + await dateRow.getByTestId('date-display').click() + + await expect(await calendarDay(page, 2026, 3, 29)).toHaveAttribute('data-selected-single', 'true') + await (await calendarDay(page, 2026, 3, 30)).click() + + await expect.poll(() => fs.readFileSync(notePath, 'utf8')).toMatch(/Date: "?2026-04-30"?/) + }) +}) From f4790d5763b29eab18f5be3d6d72ec0a82527ed5 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 29 Apr 2026 04:01:53 +0200 Subject: [PATCH 3/3] fix: detect Windows npm ai agent shims --- src-tauri/src/opencode_discovery.rs | 56 ++++++++++++++++++++++++++++- src-tauri/src/pi_discovery.rs | 56 ++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/opencode_discovery.rs b/src-tauri/src/opencode_discovery.rs index 7e2c0e77..fb514cf8 100644 --- a/src-tauri/src/opencode_discovery.rs +++ b/src-tauri/src/opencode_discovery.rs @@ -40,13 +40,21 @@ fn version_for_binary(binary: &PathBuf) -> Option { } fn find_binary_on_path() -> Option { - crate::hidden_command("which") + crate::hidden_command(path_lookup_command()) .arg("opencode") .output() .ok() .and_then(|output| path_from_successful_output(&output)) } +fn path_lookup_command() -> &'static str { + if cfg!(windows) { + "where" + } else { + "which" + } +} + fn find_binary_in_user_shell() -> Option { user_shell_candidates() .into_iter() @@ -107,12 +115,26 @@ fn opencode_binary_candidates() -> Vec { fn opencode_binary_candidates_for_home(home: &Path) -> Vec { vec![ home.join(".local/bin/opencode"), + home.join(".local/bin/opencode.exe"), home.join(".opencode/bin/opencode"), + home.join(".opencode/bin/opencode.exe"), home.join(".local/share/mise/shims/opencode"), + home.join(".local/share/mise/shims/opencode.exe"), home.join(".asdf/shims/opencode"), + home.join(".asdf/shims/opencode.exe"), home.join(".npm-global/bin/opencode"), + home.join(".npm-global/bin/opencode.cmd"), + home.join(".npm-global/bin/opencode.exe"), home.join(".npm/bin/opencode"), + home.join(".npm/bin/opencode.cmd"), + home.join(".npm/bin/opencode.exe"), home.join(".bun/bin/opencode"), + home.join(".bun/bin/opencode.exe"), + home.join("AppData/Roaming/npm/opencode.cmd"), + home.join("AppData/Roaming/npm/opencode.exe"), + home.join("AppData/Local/pnpm/opencode.cmd"), + home.join("AppData/Local/pnpm/opencode.exe"), + home.join("scoop/shims/opencode.exe"), PathBuf::from("/usr/local/bin/opencode"), PathBuf::from("/opt/homebrew/bin/opencode"), ] @@ -145,6 +167,38 @@ mod tests { } } + #[test] + fn binary_candidates_include_windows_npm_and_toolchain_shims() { + let home = PathBuf::from(r"C:\Users\alex"); + let candidates = opencode_binary_candidates_for_home(&home); + let expected = [ + home.join(".npm-global/bin/opencode.cmd"), + home.join(".npm-global/bin/opencode.exe"), + home.join(".npm/bin/opencode.cmd"), + home.join(".npm/bin/opencode.exe"), + home.join("AppData/Roaming/npm/opencode.cmd"), + home.join("AppData/Roaming/npm/opencode.exe"), + home.join("AppData/Local/pnpm/opencode.cmd"), + home.join("AppData/Local/pnpm/opencode.exe"), + home.join("scoop/shims/opencode.exe"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn path_lookup_command_matches_current_platform() { + let expected = if cfg!(windows) { "where" } else { "which" }; + + assert_eq!(path_lookup_command(), expected); + } + #[test] fn first_existing_path_skips_empty_and_missing_lines() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/pi_discovery.rs b/src-tauri/src/pi_discovery.rs index 02d3b6c5..08fb6d15 100644 --- a/src-tauri/src/pi_discovery.rs +++ b/src-tauri/src/pi_discovery.rs @@ -40,13 +40,21 @@ fn version_for_binary(binary: &PathBuf) -> Option { } fn find_binary_on_path() -> Option { - crate::hidden_command("which") + crate::hidden_command(path_lookup_command()) .arg("pi") .output() .ok() .and_then(|output| path_from_successful_output(&output)) } +fn path_lookup_command() -> &'static str { + if cfg!(windows) { + "where" + } else { + "which" + } +} + fn find_binary_in_user_shell() -> Option { user_shell_candidates() .into_iter() @@ -113,12 +121,26 @@ fn pi_binary_candidates_for_home(home: &Path) -> Vec { let mut candidates = pi_nvm_binary_candidates_for_home(home); candidates.extend([ home.join(".local/bin/pi"), + home.join(".local/bin/pi.exe"), home.join(".pi/bin/pi"), + home.join(".pi/bin/pi.exe"), home.join(".local/share/mise/shims/pi"), + home.join(".local/share/mise/shims/pi.exe"), home.join(".asdf/shims/pi"), + home.join(".asdf/shims/pi.exe"), home.join(".npm-global/bin/pi"), + home.join(".npm-global/bin/pi.cmd"), + home.join(".npm-global/bin/pi.exe"), home.join(".npm/bin/pi"), + home.join(".npm/bin/pi.cmd"), + home.join(".npm/bin/pi.exe"), home.join(".bun/bin/pi"), + home.join(".bun/bin/pi.exe"), + home.join("AppData/Roaming/npm/pi.cmd"), + home.join("AppData/Roaming/npm/pi.exe"), + home.join("AppData/Local/pnpm/pi.cmd"), + home.join("AppData/Local/pnpm/pi.exe"), + home.join("scoop/shims/pi.exe"), ]); candidates } @@ -202,6 +224,38 @@ mod tests { } } + #[test] + fn binary_candidates_include_windows_npm_and_toolchain_shims() { + let home = PathBuf::from(r"C:\Users\alex"); + let candidates = pi_binary_candidates_for_home(&home); + let expected = [ + home.join(".npm-global/bin/pi.cmd"), + home.join(".npm-global/bin/pi.exe"), + home.join(".npm/bin/pi.cmd"), + home.join(".npm/bin/pi.exe"), + home.join("AppData/Roaming/npm/pi.cmd"), + home.join("AppData/Roaming/npm/pi.exe"), + home.join("AppData/Local/pnpm/pi.cmd"), + home.join("AppData/Local/pnpm/pi.exe"), + home.join("scoop/shims/pi.exe"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn path_lookup_command_matches_current_platform() { + let expected = if cfg!(windows) { "where" } else { "which" }; + + assert_eq!(path_lookup_command(), expected); + } + #[test] fn binary_candidates_include_nvm_node_version_installs() { let home = tempfile::tempdir().unwrap();