feat(ui): absorb button in peek, preserve newlines, demote promoted items

Stream peek now shows absorb button for unpromoted entries. Promoted
items in stream show demote instead of delete. d double-tap demotes
any card_type entity regardless of view. Parsers preserve newlines
from Shift+Enter. Absorb popup truncates to first non-empty line.
This commit is contained in:
2026-05-16 13:00:22 -04:00
parent 6654907c41
commit b7dd58bf3e
3 changed files with 82 additions and 69 deletions
+10 -12
View File
@@ -212,36 +212,33 @@ func tryPrefixTime(r *Result, text string) (string, bool) {
// extractModifiers extracts tags, flags, time anchors, and card suffixes from text. // extractModifiers extracts tags, flags, time anchors, and card suffixes from text.
// handleFlags controls whether !pin is extracted (true for body, false for title/desc in some contexts). // handleFlags controls whether !pin is extracted (true for body, false for title/desc in some contexts).
func extractModifiers(r *Result, text string, handleFlags bool) (string, error) { func extractModifiers(r *Result, text string, handleFlags bool) (string, error) {
tokens := strings.Fields(text)
var parts []string
seen := map[string]bool{} seen := map[string]bool{}
for _, t := range r.Tags { for _, t := range r.Tags {
seen[strings.ToLower(t)] = true seen[strings.ToLower(t)] = true
} }
var outLines []string
for _, line := range strings.Split(text, "\n") {
tokens := strings.Fields(line)
var lineParts []string
for _, tok := range tokens { for _, tok := range tokens {
switch { switch {
// !pin flag
case handleFlags && strings.EqualFold(tok, "!pin"): case handleFlags && strings.EqualFold(tok, "!pin"):
r.Pin = true r.Pin = true
// ##word escape → literal #word in body
case strings.HasPrefix(tok, "##") && len(tok) > 2: case strings.HasPrefix(tok, "##") && len(tok) > 2:
parts = append(parts, "#"+tok[2:]) lineParts = append(lineParts, "#"+tok[2:])
// @time inline time anchor (for todos: sets due_at)
// If not valid time format, keep as body text
case strings.HasPrefix(tok, "@") && len(tok) > 1: case strings.HasPrefix(tok, "@") && len(tok) > 1:
timeStr := tok[1:] timeStr := tok[1:]
if validateTime(timeStr) != nil { if validateTime(timeStr) != nil {
parts = append(parts, tok) lineParts = append(lineParts, tok)
} else if r.TimeAnchor != nil { } else if r.TimeAnchor != nil {
return "", fmt.Errorf("multiple time anchors") return "", fmt.Errorf("multiple time anchors")
} else { } else {
r.TimeAnchor = &timeStr r.TimeAnchor = &timeStr
} }
// #tag extraction (lowercased, deduplicated)
case strings.HasPrefix(tok, "#") && len(tok) > 1: case strings.HasPrefix(tok, "#") && len(tok) > 1:
tag := strings.ToLower(tok[1:]) tag := strings.ToLower(tok[1:])
if !seen[tag] { if !seen[tag] {
@@ -249,7 +246,6 @@ func extractModifiers(r *Result, text string, handleFlags bool) (string, error)
seen[tag] = true seen[tag] = true
} }
// ^card suffix (kept for now, spec says remove later)
case strings.HasPrefix(tok, "^") && len(tok) > 1: case strings.HasPrefix(tok, "^") && len(tok) > 1:
suffix := tok[1:] suffix := tok[1:]
cardType, ok := validCardTypes[suffix] cardType, ok := validCardTypes[suffix]
@@ -262,10 +258,12 @@ func extractModifiers(r *Result, text string, handleFlags bool) (string, error)
r.CardSuffix = &cardType r.CardSuffix = &cardType
default: default:
parts = append(parts, tok) lineParts = append(lineParts, tok)
} }
} }
return strings.Join(parts, " "), nil outLines = append(outLines, strings.Join(lineParts, " "))
}
return strings.Join(outLines, "\n"), nil
} }
func validateTime(s string) error { func validateTime(s string) error {
+5
View File
@@ -94,6 +94,11 @@ func TestParse(t *testing.T) {
{"inline desc", "body text // inline desc", "body text", "note", nil, sp("inline desc"), nil, nil, nil, false, false, nil, ""}, {"inline desc", "body text // inline desc", "body text", "note", nil, sp("inline desc"), nil, nil, nil, false, false, nil, ""},
{"url no split", "http://example.com // should not split", "http://example.com // should not split", "note", nil, nil, nil, nil, nil, false, false, nil, ""}, {"url no split", "http://example.com // should not split", "http://example.com // should not split", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
// Multiline body preserves newlines
{"multiline body", "hello\nworld", "hello\nworld", "note", nil, nil, nil, nil, nil, false, false, nil, ""},
{"multiline with tags", "line one #ops\nline two", "line one\nline two", "note", nil, nil, nil, []string{"ops"}, nil, false, false, nil, ""},
{"title multiline body", "|my title\nfirst line\nsecond line", "first line\nsecond line", "note", sp("my title"), nil, nil, nil, nil, false, false, nil, ""},
// Edge cases // Edge cases
{"empty input", "", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"}, {"empty input", "", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty"},
{"only glyph", "-", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"}, {"only glyph", "-", "", "", nil, nil, nil, nil, nil, false, false, nil, "empty body"},
+19 -9
View File
@@ -229,23 +229,25 @@
// Steps 6-8: Extract flags, tags, time, card suffix // Steps 6-8: Extract flags, tags, time, card suffix
function extractModifiers(text, handleFlags) { function extractModifiers(text, handleFlags) {
const tokens = text.split(/\s+/).filter(Boolean);
const parts = [];
let localTime = timeAnchor, localPin = pin, localCard = cardSuffix; let localTime = timeAnchor, localPin = pin, localCard = cardSuffix;
const localTags = [...tags]; const localTags = [...tags];
const localSeen = { ...seenTags }; const localSeen = { ...seenTags };
const outLines = [];
for (const line of text.split('\n')) {
const tokens = line.split(/[ \t]+/).filter(Boolean);
const lineParts = [];
for (const tok of tokens) { for (const tok of tokens) {
if (handleFlags && tok.toLowerCase() === '!pin') { if (handleFlags && tok.toLowerCase() === '!pin') {
localPin = true; localPin = true;
} else if (tok.startsWith('##') && tok.length > 2) { } else if (tok.startsWith('##') && tok.length > 2) {
parts.push('#' + tok.slice(2)); lineParts.push('#' + tok.slice(2));
} else if (tok.startsWith('@') && tok.length > 1) { } else if (tok.startsWith('@') && tok.length > 1) {
const ts = tok.slice(1); const ts = tok.slice(1);
if (validateTime(ts) && localTime === null) { if (validateTime(ts) && localTime === null) {
localTime = ts; localTime = ts;
} else { } else {
parts.push(tok); lineParts.push(tok);
} }
} else if (tok.startsWith('#') && tok.length > 1) { } else if (tok.startsWith('#') && tok.length > 1) {
const tag = tok.slice(1).toLowerCase(); const tag = tok.slice(1).toLowerCase();
@@ -253,12 +255,14 @@
} else if (tok.startsWith('^') && tok.length > 1) { } else if (tok.startsWith('^') && tok.length > 1) {
const suffix = tok.slice(1); const suffix = tok.slice(1);
if (VALID_CARDS[suffix] && localCard === null) localCard = VALID_CARDS[suffix]; if (VALID_CARDS[suffix] && localCard === null) localCard = VALID_CARDS[suffix];
else parts.push(tok); else lineParts.push(tok);
} else { } else {
parts.push(tok); lineParts.push(tok);
} }
} }
return { body: parts.join(' '), timeAnchor: localTime, tags: localTags, seen: localSeen, cardSuffix: localCard, pin: localPin }; outLines.push(lineParts.join(' '));
}
return { body: outLines.join('\n'), timeAnchor: localTime, tags: localTags, seen: localSeen, cardSuffix: localCard, pin: localPin };
} }
let title = null, description = null; let title = null, description = null;
@@ -737,8 +741,13 @@
let actions = ''; let actions = '';
if (!e.card_type) { if (!e.card_type) {
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote →</button>`; actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote →</button>`;
actions += `<button class="action-btn" onclick="nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
} }
if (e.card_type) {
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
} else {
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`; actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
}
return `<div class="peek-scroll"> return `<div class="peek-scroll">
<div class="peek-brow"> <div class="peek-brow">
@@ -1271,7 +1280,8 @@
list.innerHTML = sources.map(e => { list.innerHTML = sources.map(e => {
const g = displayGlyph(e); const g = displayGlyph(e);
const gc = glyphClass(e); const gc = glyphClass(e);
const label = e.title ? escHtml(e.title) : escHtml(e.body); const rawLabel = e.title || (e.body || '').split('\n').find(l => l.trim()) || '';
const label = escHtml(rawLabel.length > 80 ? rawLabel.slice(0, 80) + '…' : rawLabel);
return `<div class="absorb-source-item" data-id="${e.id}"> return `<div class="absorb-source-item" data-id="${e.id}">
<span class="entity-glyph ${gc}">${g}</span> <span class="entity-glyph ${gc}">${g}</span>
<span class="entity-body">${label}</span> <span class="entity-body">${label}</span>
@@ -1460,7 +1470,7 @@
const now = Date.now(); const now = Date.now();
if (now - lastDTime < 400) { if (now - lastDTime < 400) {
if (sel) { if (sel) {
if (sel.card_type && state.view === 'cards') nibApp.demoteEntity(sel.id); if (sel.card_type) nibApp.demoteEntity(sel.id);
else nibApp.deleteEntity(sel.id); else nibApp.deleteEntity(sel.id);
} }
lastDTime = 0; lastDTime = 0;