Skip to content

Commit f604a60

Browse files
author
ShowhyT
authored
Merge branch 'main' into fix/hide-window-controls-web-runtime
2 parents e777709 + a547d5f commit f604a60

25 files changed

Lines changed: 983 additions & 45 deletions

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@zennotes/desktop",
33
"productName": "ZenNotes",
4-
"version": "2.17.0",
4+
"version": "2.18.0",
55
"description": "ZenNotes desktop shell",
66
"private": true,
77
"main": "./out/main/index.js",

apps/desktop/src/main/app-config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
240240
comment: 'code / monospace font; empty = system default'
241241
},
242242
// view
243+
assetSortOrder: {
244+
section: 'view',
245+
tomlKey: 'asset_sort_order',
246+
comment: 'Assets view sort: name | used | type | size | modified, each -asc or -desc'
247+
},
243248
noteSortOrder: {
244249
section: 'view',
245250
tomlKey: 'note_sort_order',

apps/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@zennotes/server",
33
"private": true,
4-
"version": "2.17.0",
4+
"version": "2.18.0",
55
"scripts": {
66
"dev": "node ../../tooling/scripts/run-go-server-dev.mjs",
77
"prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs",

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@zennotes/web",
33
"private": true,
4-
"version": "2.17.0",
4+
"version": "2.18.0",
55
"type": "module",
66
"description": "ZenNotes web client for self-hosted and hosted deployments",
77
"homepage": "https://zennotes.org",

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zennotes-monorepo",
33
"private": true,
4-
"version": "2.17.0",
4+
"version": "2.18.0",
55
"description": "ZenNotes monorepo for desktop, web, and self-hosted server builds",
66
"packageManager": "npm@10.9.2",
77
"engines": {
@@ -32,7 +32,8 @@
3232
"pack": "npm run pack --workspace @zennotes/desktop",
3333
"dist:mac": "npm run dist:mac --workspace @zennotes/desktop",
3434
"dist:win": "npm run dist:win --workspace @zennotes/desktop",
35-
"dist:linux": "npm run dist:linux --workspace @zennotes/desktop"
35+
"dist:linux": "npm run dist:linux --workspace @zennotes/desktop",
36+
"perf:editor-scroll": "node tooling/scripts/perf-editor-scroll.mjs"
3637
},
3738
"devDependencies": {
3839
"turbo": "^2.5.8"

packages/app-core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@zennotes/app-core",
33
"private": true,
4-
"version": "2.17.0",
4+
"version": "2.18.0",
55
"type": "module",
66
"exports": {
77
"./main": "./src/main.tsx"

packages/app-core/src/components/AssetsView-sort.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import { describe, expect, it } from 'vitest'
44
import type { AssetMeta } from '@shared/ipc'
5-
import { sortAssets, type AssetSort } from './AssetsView'
5+
import {
6+
assetSortOrderOf,
7+
parseAssetSortOrder,
8+
sortAssets,
9+
type AssetSort
10+
} from './AssetsView'
611

712
// #460: the Assets list can be sorted by any column.
813
function asset(over: Partial<AssetMeta>): AssetMeta {
@@ -76,3 +81,28 @@ describe('#460 — sortAssets', () => {
7681
expect(input).toEqual([C, A, B])
7782
})
7883
})
84+
85+
// #473: the chosen column is stored as one `<column>-<dir>` preference, so it
86+
// survives leaving the view (and restarting the app).
87+
describe('#473: asset sort order round-trip', () => {
88+
const ALL: AssetSort[] = (['name', 'used', 'type', 'size', 'modified'] as const).flatMap((key) =>
89+
(['asc', 'desc'] as const).map((dir) => ({ key, dir }))
90+
)
91+
92+
it('round-trips every column and direction', () => {
93+
for (const sort of ALL) {
94+
expect(parseAssetSortOrder(assetSortOrderOf(sort))).toEqual(sort)
95+
}
96+
})
97+
98+
it('formats to the values the store accepts', () => {
99+
expect(assetSortOrderOf({ key: 'modified', dir: 'desc' })).toBe('modified-desc')
100+
expect(assetSortOrderOf({ key: 'name', dir: 'asc' })).toBe('name-asc')
101+
})
102+
103+
it('sorts the same whether given the parsed pref or a literal', () => {
104+
expect(names(sortAssets(LIST, usage, parseAssetSortOrder('size-desc')))).toEqual(
105+
names(sortAssets(LIST, usage, { key: 'size', dir: 'desc' }))
106+
)
107+
})
108+
})

packages/app-core/src/components/AssetsView.tsx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useMemo, useState } from 'react'
22
import type { AssetMeta } from '@shared/ipc'
3-
import { useStore } from '../store'
3+
import { useStore, type AssetSortColumn, type AssetSortOrder } from '../store'
44
import { assetTabPath } from '../lib/asset-tabs'
55
import { confirmMoveToTrash } from '../lib/confirm-trash'
66
import { promptApp } from '../lib/prompt-requests'
@@ -37,12 +37,28 @@ const ASSET_ROW_GRID =
3737
'grid grid-cols-[minmax(0,1fr)_6rem_4rem_5rem_5rem_1.75rem] items-center gap-4'
3838

3939
// Which column the Assets list is sorted by. (#460)
40-
export type AssetSortKey = 'name' | 'used' | 'type' | 'size' | 'modified'
40+
export type AssetSortKey = AssetSortColumn
4141
export interface AssetSort {
4242
key: AssetSortKey
4343
dir: 'asc' | 'desc'
4444
}
4545

46+
/** Split the stored `<column>-<dir>` preference into the shape `sortAssets`
47+
* takes. The value is validated in the store, so a bad split can't reach
48+
* here; fall back to the default anyway rather than sorting by `undefined`. (#473) */
49+
export function parseAssetSortOrder(order: AssetSortOrder): AssetSort {
50+
const at = order.lastIndexOf('-')
51+
const key = order.slice(0, at) as AssetSortKey
52+
const dir = order.slice(at + 1) as AssetSort['dir']
53+
if (!key || (dir !== 'asc' && dir !== 'desc')) return { key: 'name', dir: 'asc' }
54+
return { key, dir }
55+
}
56+
57+
/** Inverse of `parseAssetSortOrder`, for writing the preference back. (#473) */
58+
export function assetSortOrderOf(sort: AssetSort): AssetSortOrder {
59+
return `${sort.key}-${sort.dir}` as AssetSortOrder
60+
}
61+
4662
/** Display label for a note path in the "used by" menu — its filename. */
4763
function noteLabel(notePath: string): string {
4864
return notePath.split('/').pop()?.replace(/\.md$/i, '') ?? notePath
@@ -103,10 +119,11 @@ export function AssetsView(): JSX.Element {
103119
const [filter, setFilter] = useState('')
104120
const [menu, setMenu] = useState<{ x: number; y: number; asset: AssetMeta } | null>(null)
105121
const [usageMenu, setUsageMenu] = useState<{ x: number; y: number; notes: string[] } | null>(null)
106-
const [sort, setSort] = useState<{ key: AssetSortKey; dir: 'asc' | 'desc' }>({
107-
key: 'name',
108-
dir: 'asc'
109-
})
122+
// Sort lives in the store (and config.toml), not local state, so leaving the
123+
// view and coming back keeps the column you picked. (#473)
124+
const assetSortOrder = useStore((s) => s.assetSortOrder)
125+
const setAssetSortOrder = useStore((s) => s.setAssetSortOrder)
126+
const sort = useMemo(() => parseAssetSortOrder(assetSortOrder), [assetSortOrder])
110127

111128
// assetPath → note paths that embed it (resolved via relative-path + the
112129
// unique-basename fallback, matching how embeds render). (#185)
@@ -138,11 +155,11 @@ export function AssetsView(): JSX.Element {
138155
// Click a header: sort by it, or flip direction if it's already active. Text
139156
// columns start ascending; count/size/date start descending (biggest first).
140157
const toggleSort = (key: AssetSortKey): void => {
141-
setSort((prev) =>
142-
prev.key === key
143-
? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
158+
const next: AssetSort =
159+
sort.key === key
160+
? { key, dir: sort.dir === 'asc' ? 'desc' : 'asc' }
144161
: { key, dir: key === 'name' || key === 'type' ? 'asc' : 'desc' }
145-
)
162+
setAssetSortOrder(assetSortOrderOf(next))
146163
}
147164

148165
const copyEmbed = (asset: AssetMeta): void => {

packages/app-core/src/components/DatabaseView.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,13 @@ export function DatabaseView({
186186
}
187187
size="sm"
188188
title="Filter rows"
189-
onClick={(e) =>
190-
setFilterAnchor((prev) =>
191-
prev ? null : e.currentTarget.getBoundingClientRect()
192-
)
193-
}
189+
onClick={(e) => {
190+
// Read the anchor rect during dispatch — React nulls the
191+
// synthetic event's currentTarget once dispatch ends, and
192+
// the updater can run deferred (crashes the root otherwise).
193+
const rect = e.currentTarget.getBoundingClientRect()
194+
setFilterAnchor((prev) => (prev ? null : rect))
195+
}}
194196
>
195197
<FilterIcon className="h-3.5 w-3.5" /> Filter
196198
{(activeView.filters?.length ?? 0) > 0 ? ` (${activeView.filters.length})` : ''}

packages/app-core/src/lib/cm-live-preview.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,79 @@ describe('livePreviewPlugin', () => {
5454
view.destroy()
5555
})
5656

57+
it('keeps a half-typed link readable while the target is written (#471)', () => {
58+
// `[Example](` parses as `Link[0,9]`: the node stops at `]` because the
59+
// target is unclosed, so the brackets used to be hidden and the label
60+
// rendered as `Example(`. Nothing collapses until the `)` lands.
61+
const steps = ['[Example](', '[Example](h', '[Example](https://example.com']
62+
for (const doc of steps) {
63+
const view = mountEditor(doc, doc.length)
64+
expect(view.dom.textContent).toBe(doc)
65+
view.destroy()
66+
}
67+
68+
// The closing paren completes the link: now it renders as the label alone.
69+
const done = mountEditor('[Example](https://example.com)\n\nfar', 32)
70+
expect(done.dom.textContent).toContain('Example')
71+
expect(done.dom.textContent).not.toContain('https://example.com')
72+
done.destroy()
73+
})
74+
75+
it('keeps a pasted URL visible in a half-typed link even off the caret line (#471)', () => {
76+
// The URL parses as a GFM autolink hanging off the paragraph, not as the
77+
// link destination. It follows a `(`, which used to be enough to hide it,
78+
// so the pasted URL disappeared outright.
79+
const doc = 'see [Example](https://example.com\n\nfar away'
80+
const view = mountEditor(doc, doc.length)
81+
82+
expect(view.dom.textContent).toContain('see [Example](https://example.com')
83+
84+
view.destroy()
85+
})
86+
87+
it('treats a half-typed image target the same way (#471)', () => {
88+
const doc = '![alt](https://example.com'
89+
const view = mountEditor(doc, doc.length)
90+
91+
expect(view.dom.textContent).toBe(doc)
92+
93+
view.destroy()
94+
})
95+
96+
it('counts nested parens when deciding a link target is closed (#471)', () => {
97+
// CommonMark destinations may contain balanced parens, so the first `)`
98+
// is not necessarily the end of the target.
99+
const open = '[wiki](https://en.wikipedia.org/wiki/Foo_(bar'
100+
const half = mountEditor(`${open}\n\nfar`, open.length + 5)
101+
expect(half.dom.textContent).toContain(open)
102+
half.destroy()
103+
104+
const closed = mountEditor(`${open}))\n\nfar`, open.length + 7)
105+
expect(closed.dom.textContent).toContain('wiki')
106+
expect(closed.dom.textContent).not.toContain('en.wikipedia.org')
107+
closed.destroy()
108+
})
109+
110+
it('keeps a parenthesised bare URL visible (#471)', () => {
111+
// `(https://…)` is an autolink wrapped in prose parens, not a link target.
112+
const doc = 'a (https://parens.example) b\n\nfar away'
113+
const view = mountEditor(doc, doc.length)
114+
115+
expect(view.dom.textContent).toContain('a (https://parens.example) b')
116+
117+
view.destroy()
118+
})
119+
120+
it('keeps hiding the destination of a completed link (#471 regression guard)', () => {
121+
const doc = '[label](https://a.com) tail\n\nfar away'
122+
const view = mountEditor(doc, doc.length)
123+
124+
expect(view.dom.textContent).toContain('label tail')
125+
expect(view.dom.textContent).not.toContain('https://a.com')
126+
127+
view.destroy()
128+
})
129+
57130
it('keeps the colon visible in a reference-link definition (#188)', () => {
58131
// The `:` parses as a LinkMark; live preview must not hide it, or the
59132
// definition reads as a broken `[label] url`.

0 commit comments

Comments
 (0)