Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/array-editor-effect-loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
### New Features
- Alt+J previews the whole row as JSON in the bottom dock, and follows the cursor from row to row. Also on the cell right-click menu
- JSON colours are selectable in Settings → Appearance: Auto, Vivid, Ocean, Solarized, GitHub and Monochrome

### Bug Fixes
- Opening an array cell no longer takes the view down with "This view hit an error"
- Scrolling no longer tears, leaving a band of repeated rows above a seam
- A cell holding multi-line text reads on one line instead of showing gaps where the line breaks were
- Control characters show what they are instead of an empty box
- The switch keeps its thumb inside its track at every zoom level

### Changes
- Array columns read as JSON, the same form the editor and the jsonb column beside them already used
- The array editor reads as one list instead of a stack of separate fields, and its row controls stay put instead of appearing under the pointer
- The expanded row and its JSON tree sit on one type scale, and a key's colon sits against the key
- Settings descriptions are one line each instead of a paragraph
- Opening a schema with hundreds of tables costs one icon-settings subscription instead of one per icon
66 changes: 66 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,72 @@ html[data-theme="light"] {
--json-number: oklch(0.55 0.13 60);
--json-boolean: oklch(0.5 0.18 310);
}

/* Selectable JSON palettes (Settings → Appearance → JSON colours).
`auto` is the pair above: it follows light and dark and is the default.
Each of the rest is written twice, once for dark surfaces and once for
light, because a palette tuned for one is unreadable on the other - the
lightness moves, the hue does not. */
[data-json-theme="vivid"] {
--json-key: oklch(0.78 0.16 250);
--json-string: oklch(0.82 0.19 145);
--json-number: oklch(0.85 0.17 80);
--json-boolean: oklch(0.78 0.21 320);
}
html[data-theme="light"][data-json-theme="vivid"],
html[data-theme="light"] [data-json-theme="vivid"] {
--json-key: oklch(0.48 0.21 255);
--json-string: oklch(0.45 0.18 150);
--json-number: oklch(0.5 0.17 60);
--json-boolean: oklch(0.46 0.24 320);
}

[data-json-theme="ocean"] {
--json-key: oklch(0.76 0.11 230);
--json-string: oklch(0.79 0.11 195);
--json-number: oklch(0.82 0.09 160);
--json-boolean: oklch(0.72 0.13 265);
}
html[data-theme="light"][data-json-theme="ocean"],
html[data-theme="light"] [data-json-theme="ocean"] {
--json-key: oklch(0.46 0.14 235);
--json-string: oklch(0.45 0.11 200);
--json-number: oklch(0.48 0.1 165);
--json-boolean: oklch(0.44 0.16 270);
}

/* Solarized's own accent values, which are defined once and used on both of
its backgrounds - that is the point of the palette. */
[data-json-theme="solarized"] {
--json-key: oklch(0.63 0.11 240); /* blue #268bd2 */
--json-string: oklch(0.68 0.1 190); /* cyan #2aa198 */
--json-number: oklch(0.68 0.13 130);/* green #859900 */
--json-boolean: oklch(0.6 0.15 350);/* magenta #d33682 */
}

[data-json-theme="github"] {
--json-key: oklch(0.72 0.14 280); /* purple */
--json-string: oklch(0.75 0.11 235);/* blue */
--json-number: oklch(0.76 0.13 45); /* orange */
--json-boolean: oklch(0.7 0.16 20); /* red */
}
html[data-theme="light"][data-json-theme="github"],
html[data-theme="light"] [data-json-theme="github"] {
--json-key: oklch(0.45 0.19 285);
--json-string: oklch(0.42 0.15 240);
--json-number: oklch(0.5 0.15 45);
--json-boolean: oklch(0.48 0.19 20);
}

/* No hue at all: the structure carries on the shades and the app theme's own
accent is the only colour left on screen. */
[data-json-theme="monochrome"] {
--json-key: var(--foreground);
--json-string: color-mix(in oklch, var(--foreground) 72%, transparent);
--json-number: color-mix(in oklch, var(--foreground) 88%, transparent);
--json-boolean: color-mix(in oklch, var(--foreground) 60%, transparent);
--json-null: color-mix(in oklch, var(--foreground) 42%, transparent);
}
.json-tok-key { color: var(--json-key); }
.json-tok-str { color: var(--json-string); }
.json-tok-num { color: var(--json-number); }
Expand Down
58 changes: 38 additions & 20 deletions src/lib/components/ArrayCellEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,16 @@
// Seed the working copy whenever the editor opens for a new cell.
$effect(() => {
if (open) {
items = (Array.isArray(value) ? value : []).map((el) => ({
// Built into a local and read from it. Assigning `items` and then reading
// `items.length` on the next line made this effect depend on the state it
// had just written - and since each run assigns a brand new array, the
// dependency fired again every time. Opening an array cell took the whole
// view down with effect_update_depth_exceeded.
const next = (Array.isArray(value) ? value : []).map((el) => ({
v: el === null || el === undefined ? null : String(el),
}))
inputEls.length = items.length
items = next
inputEls.length = next.length
}
})

Expand Down Expand Up @@ -100,7 +106,7 @@
onclick={(e) => { if (e.target === e.currentTarget) cancel() }}
onkeydown={(e) => { if (e.key === 'Escape') { e.preventDefault(); cancel() } }}
>
<div class="flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-border/50 bg-background elevate-3-rim">
<div class="flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-border/50 bg-background elevate-3-rim">
<!-- Header -->
<div class="flex items-center gap-3 border-b border-border/15 px-4 py-3.5">
<div class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
Expand All @@ -124,8 +130,10 @@
</button>
</div>

<!-- Elements -->
<div class="min-h-0 flex-1 overflow-y-auto p-2">
<!-- Elements. One bordered surface with hairline separators rather than a
bordered box per row: eleven outlines stacked up read as eleven
fields to fill in, when what this is is one list. -->
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-2.5">
{#if items.length === 0}
<div class="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border/50 px-4 py-8 text-center">
<p class="text-ui-xs text-muted-foreground">Empty array <span class="font-mono">{'{}'}</span></p>
Expand All @@ -138,21 +146,22 @@
</button>
</div>
{:else}
<div class="flex flex-col">
<div class="flex flex-col overflow-hidden rounded-lg border border-border/40 bg-muted/10">
{#each items as item, i (i)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={cn(
'group relative flex items-center gap-2 rounded-lg px-1 py-1 transition-[opacity,background-color] duration-150',
'group relative flex h-8 items-center gap-1.5 px-1.5 transition-[opacity,background-color] duration-150',
i > 0 && 'border-t border-border/25',
dragIndex === i && 'opacity-40',
overIndex === i && dragIndex !== i && 'bg-primary/5',
overIndex === i && dragIndex !== i ? 'bg-primary/10' : 'hover:bg-muted/25',
)}
ondragover={(e) => onDragOver(i, e)}
ondrop={() => onDrop(i)}
>
<!-- Drop indicator line above the hovered row -->
{#if overIndex === i && dragIndex !== null && dragIndex !== i}
<span class="pointer-events-none absolute inset-x-2 -top-px h-0.5 rounded-full bg-primary"></span>
<span class="pointer-events-none absolute inset-x-0 -top-px h-0.5 bg-primary"></span>
{/if}
<!-- Drag handle -->
<button
Expand All @@ -161,15 +170,15 @@
draggable="true"
ondragstart={(e) => onDragStart(i, e)}
ondragend={onDragEnd}
class="flex size-6 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground transition-colors hover:text-muted-foreground active:cursor-grabbing"
class="flex size-5 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground/35 transition-colors group-hover:text-muted-foreground active:cursor-grabbing"
>
<GripVertical class="size-3.5" />
</button>
<span class="w-5 shrink-0 text-right font-mono text-ui-2xs tabular-nums text-muted-foreground">{i}</span>
<span class="w-6 shrink-0 pr-1 text-right font-mono text-ui-3xs tabular-nums text-muted-foreground/60">{i}</span>
{#if item.v === null}
<button
type="button"
class= "field-surface flex h-8 flex-1 items-center bg-muted/15 px-2.5 text-ui-xs font-medium italic tracking-wide text-warning transition-colors hover:bg-muted/25"
class="flex h-7 flex-1 items-center rounded-md px-2 text-left font-mono text-ui-xs italic text-warning transition-colors hover:bg-muted/40 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onclick={() => toggleNull(i)}
title="Click to enter a value"
>NULL</button>
Expand All @@ -180,17 +189,23 @@
placeholder="value"
spellcheck="false"
autocomplete="off"
class= "field-surface h-8 min-w-0 flex-1 bg-muted/15 px-2.5 font-mono text-ui-xs leading-none text-foreground outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground hover:"
class="h-7 min-w-0 flex-1 rounded-md bg-transparent px-2 font-mono text-ui-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/50 hover:bg-muted/25 focus:bg-muted/30 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addItem() } }}
/>
{/if}
<div class="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100">
<!-- Always there, just quiet. Fading them in on hover moved
nothing but made the row look different every time the
pointer crossed it, and left them unreachable by keyboard. -->
<div class="flex shrink-0 items-center gap-0.5">
<button type="button" aria-label={item.v === null ? 'Clear NULL' : 'Set NULL'}
class={cn('inline-flex size-7 items-center justify-center rounded-md transition-colors hover:bg-muted/50', item.v === null ? 'text-warning' : 'text-muted-foreground hover:text-foreground')}
class={cn(
'inline-flex size-6 items-center justify-center rounded transition-colors hover:bg-muted/60 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring',
item.v === null ? 'text-warning' : 'text-muted-foreground/40 group-hover:text-muted-foreground hover:text-foreground',
)}
title={item.v === null ? 'Clear NULL' : 'Set NULL'}
onclick={() => toggleNull(i)}><CircleSlash class="size-3.5" /></button>
<button type="button" aria-label="Remove element"
class="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/15 hover:text-destructive"
class="inline-flex size-6 items-center justify-center rounded text-muted-foreground/40 transition-colors group-hover:text-muted-foreground hover:bg-destructive/15 hover:text-destructive focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
title="Remove"
onclick={() => removeAt(i)}><X class="size-3.5" /></button>
</div>
Expand All @@ -201,7 +216,7 @@
</div>

<!-- Footer -->
<div class="flex items-center gap-2 border-t border-border/15 px-3 py-2.5">
<div class="flex items-center gap-1.5 border-t border-border/15 bg-muted/10 px-3 py-2.5">
<button
type="button"
class= "field-surface inline-flex h-8 items-center gap-1.5 px-2.5 text-ui-xs font-medium text-muted-foreground transition-[color,background-color,transform] duration-150 ease-out hover:bg-muted/40 hover:text-foreground active:scale-[0.96]"
Expand All @@ -212,19 +227,22 @@
{#if items.length > 0}
<button
type="button"
class="inline-flex h-8 items-center rounded-md px-2.5 text-ui-xs text-muted-foreground transition-colors hover:text-destructive"
class="inline-flex h-8 items-center rounded-md px-2 text-ui-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
onclick={clearAll}
>Clear</button>
>Clear all</button>
{/if}
<div class="ml-auto flex items-center gap-2">
<button
type="button"
class= "field-surface inline-flex h-8 items-center px-3 text-ui-xs text-muted-foreground transition-[color,background-color,transform] duration-150 ease-out hover:bg-muted/40 hover:text-foreground active:scale-[0.96]"
onclick={cancel}
>Cancel</button>
<!-- bg-primary, the way ConfirmDialog and the update dialog do it. It
was the only primary button in the app painted in the foreground
colour. -->
<button
type="button"
class="inline-flex h-8 items-center rounded-md bg-foreground px-4 text-ui-xs font-medium text-background transition-[background-color,transform] duration-150 ease-out hover:bg-foreground/85 active:scale-[0.96]"
class="inline-flex h-8 items-center rounded-md bg-primary px-4 text-ui-xs font-medium text-primary-foreground transition-[background-color,transform] duration-150 ease-out hover:bg-primary/90 active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
onclick={save}
>Save</button>
</div>
Expand Down
9 changes: 7 additions & 2 deletions src/lib/components/CellEditorPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,13 @@
// Structured text opens unwrapped: pretty-printed JSON is short lines
// already, and unwrapped is what lets the gutter number them. Prose keeps
// wrapping. Alt+Z still flips it either way.
maxLineLen = longestLine(text)
wrap = maxLineLen <= MAX_WRAP_LINE && (wrapPref ?? !/^\s*[[{]/.test(text))
// Measured into a local and used from it. Reading `maxLineLen` back on the
// next line made this effect depend on a value it had just written, which
// is the read-and-write cycle Svelte refuses to run - it took down the
// whole view with effect_update_depth_exceeded on any cell that reached it.
const longest = longestLine(text)
maxLineLen = longest
wrap = longest <= MAX_WRAP_LINE && (wrapPref ?? !/^\s*[[{]/.test(text))
// Undo/redo, word-delete and line-delete for every plain field in the app
// live in `input-shortcuts.js`, and its history is keyed by element. This
// textarea outlives the cell it is showing, so the history has to be
Expand Down
Loading
Loading