feat(workspace): journal panel toggle, view kept across tabs, sections nest under headings
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m9s

- the journal opens as a framed panel and closes on a second click,
  returning to the previous note; journal state and the last regular note
  survive tab switches, the fallback note is never a journal day
- pasted and imported markdown nests content under its heading (H2 below
  H1), Enter on a heading creates a child, '- ' / '* ' / '+ ' start a bullet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 20:11:09 +01:00
1 parent bc7bdc481e
commit 375dda982b
9 files changed
+145 -36

No files matched your search

+4
View File
@@ -6,6 +6,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
### Changed
- Journal: Geöffnet ist es ein hinterlegtes Panel mit Rahmen, „Journal“ ist dessen Überschrift; ein zweiter Klick schließt es und zeigt wieder die vorherige Notiz. Ob das Journal offen war und welche Notiz offen war, bleibt beim Wechsel zwischen den Tabs erhalten (Journal-Tage öffnen sich nicht mehr als einzelne Notiz).
- Notizen: Beim Einfügen und beim Markdown-Import werden Absätze und Listen unter einer Überschrift eingerückt (H2 unter H1 eine Ebene tiefer), so lassen sich Abschnitte einklappen und fokussieren. Enter am Ende einer Überschrift legt den neuen Punkt als Unterpunkt an; `- `, `* ` oder `+ ` in einem leeren Punkt machen ihn zum normalen Aufzählungspunkt.
## [0.10.0] - 2026-10-30
### Added
@@ -622,6 +622,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
val = val.replace(/^\[[xX]\]\s/, '');
typeOverride = 'todo';
checkedOverride = true;
} else if (val === '- ' || val === '* ' || val === '+ ') {
// Markdown list marker typed into an empty block: it already is a bullet, drop the marker
val = '';
if (!['text', 'bullet', 'paragraph'].includes(block.type || 'text')) typeOverride = 'bullet';
} else if ((val === '1. ' || val === '1) ') && block.type !== 'numbered') {
// "1. " typed into an empty bullet starts a numbered list
val = '';
@@ -247,7 +247,11 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === afterBlockId);
const currentBlock = idx >= 0 ? blocks[idx] : null;
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
const isHeading =
(currentBlock?.type === 'h1' || currentBlock?.type === 'h2' || currentBlock?.type === 'h3') &&
!currentBlock.collapsed;
// Enter on an open heading starts its section: the new bullet goes below it as a child
const level = currentBlock ? Math.max(0, currentBlock.level || 0) + (isHeading ? 1 : 0) : 0;
// Numbered lists continue on Enter (an empty item ends the list, see OutlinerBlock)
const inheritedType: BlockType = currentBlock?.type === 'numbered' ? 'numbered' : 'text';
@@ -53,7 +53,7 @@ import { PresenceAvatars } from './PresenceAvatars';
import { TrashView } from './TrashView';
import { JournalView } from './JournalView';
import { JournalCalendar } from './JournalCalendar';
import { isJournalEnabled, todayISO } from '../../lib/journal';
import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal';
import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags';
@@ -319,10 +319,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}, [docs, serverTagsSummary]);
const [selectedDocId, setSelectedDocId] = useState<string | null>(initialDocId || null);
const [selectedDocId, setSelectedDocId] = useState<string | null>(
initialDocId || readWorkspaceSession().docId || null
);
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
// Journal open and the last regular note survive switching to other tabs (the view remounts)
const [subView, setSubView] = useState<WorkspaceSubView>(() =>
!initialDocId && isJournalEnabled() && readWorkspaceSession().journal ? 'journal' : 'editor'
);
const [trash, setTrash] = useState<TrashedDocument[]>([]);
// Bumped after a restore so the open editor reloads the note without the trash banner
const [editorNonce, setEditorNonce] = useState(0);
@@ -398,7 +403,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
setShowNewFolderModal(true);
};
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(initialDocId));
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(
Boolean(initialDocId) || subView === 'journal'
);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem('mindsift:workspace_sidebar_collapsed') === 'true';
@@ -473,12 +480,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const loadWorkspaceData = useCallback(async () => {
try {
const [docsRes, foldersRes, sharedFoldersRes, tagsRes] = await Promise.all([
const [docsRes, foldersRes, sharedFoldersRes, tagsRes, journalRes] = await Promise.all([
api.getDocuments(),
api.getFolders(),
api.getSharedFolders().catch(() => []),
api.getWorkspaceTags().catch(() => []),
isJournalEnabled() ? api.getJournal().catch(() => [] as JournalDay[]) : Promise.resolve([] as JournalDay[]),
]);
const journalIds = new Set(journalRes.map((d) => d.docId));
setDocs(docsRes);
setFolders(foldersRes);
setSharedFolders(sharedFoldersRes);
@@ -488,7 +497,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (prevId && docsRes.some((d) => d.id === prevId)) {
return prevId;
}
return docsRes.length > 0 ? docsRes[0].id : null;
// Not a journal day: those open in the journal
const first = docsRes.find((d) => !(d.isOwner && journalIds.has(d.id)));
return first ? first.id : null;
});
} catch {
// ignore
@@ -620,6 +631,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setMobileShowDetail(true);
}, [reloadJournal]);
// A second click closes the journal and shows the regular note again
const toggleJournal = useCallback(() => {
if (subView === 'journal') {
setSubView('editor');
setSelectedDocId((prev) => prev ?? treeDocsRef.current[0]?.id ?? null);
} else {
openJournal();
}
}, [subView, openJournal]);
const openTrash = useCallback(() => {
reloadTrash();
setSubView('trash');
@@ -1004,6 +1025,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
() => (journalDocIds.size ? docs.filter((d) => !(d.isOwner && journalDocIds.has(d.id))) : docs),
[docs, journalDocIds]
);
const treeDocsRef = useRef(treeDocs);
treeDocsRef.current = treeDocs;
useEffect(() => {
writeWorkspaceSession({ journal: subView === 'journal', docId: selectedDocId });
}, [subView, selectedDocId]);
const allFolderPaths = useMemo(() => {
const set = new Set<string>(folders);
@@ -1569,28 +1596,31 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{/* Journal on top; while open, its calendar stays below it */}
{/* Journal on top; open, it becomes a panel with the calendar (click the heading to close) */}
{journalEnabled && (
<div className="border-b border-border pb-1.5 -mb-1.5">
<div
className={
subView === 'journal'
? 'rounded-card border-2 border-accent/40 bg-panel-elevated p-2.5'
: 'border-b border-border pb-1.5 -mb-1.5'
}
>
<button
type="button"
onClick={openJournal}
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
onClick={toggleJournal}
aria-expanded={subView === 'journal'}
title={subView === 'journal' ? 'Journal schließen' : 'Journal öffnen'}
className={`w-full flex items-center gap-2 text-left cursor-pointer transition-colors ${
subView === 'journal'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
? 'h-11 sm:h-7 px-0.5 text-sm font-bold text-main'
: 'h-11 sm:h-8 px-2 rounded-btn text-xs text-main border border-transparent hover:bg-panel-elevated'
}`}
>
<CalendarDays className="w-3.5 h-3.5 text-accent" />
<span className="flex-1 text-left">Journal</span>
{subView === 'journal' ? (
<ChevronDown className="w-3.5 h-3.5 text-muted" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-muted" />
)}
<CalendarDays className={subView === 'journal' ? 'w-4 h-4 text-accent' : 'w-3.5 h-3.5 text-accent'} />
<span>Journal</span>
</button>
{subView === 'journal' && (
<div className="hidden lg:block px-1 pt-2 pb-1">
<div className="hidden lg:block pt-2">
<JournalCalendar
activeDate={journalActive}
entryDates={new Set(journalDays.filter((d) => !d.empty).map((d) => d.date))}
@@ -20,13 +20,27 @@ describe('parsePastedLines', () => {
]);
});
it('reads headings, todos, bullets and dividers', () => {
expect(parse('# Titel\nText danach\n- [x] erledigt\n- Punkt\n---')).toEqual([
it('reads headings, todos, bullets and dividers and nests sections under headings', () => {
expect(parse('# Titel\nText danach\n- [x] erledigt\n* Punkt\n + tiefer\n---\nOhne Abschnitt')).toEqual([
{ type: 'h1', content: 'Titel', indent: 0 },
{ type: 'text', content: 'Text danach', indent: 0 },
{ type: 'todo', checked: true, content: 'erledigt', indent: 0 },
{ type: 'text', content: 'Punkt', indent: 0 },
{ type: 'text', content: 'Text danach', indent: 1 },
{ type: 'todo', checked: true, content: 'erledigt', indent: 1 },
{ type: 'text', content: 'Punkt', indent: 1 },
{ type: 'text', content: 'tiefer', indent: 2 },
{ type: 'divider', content: '---', indent: 0 },
{ type: 'text', content: 'Ohne Abschnitt', indent: 0 },
]);
});
it('nests H2 below H1 and closes sections at the same rank', () => {
expect(parse('# A\n## A1\n1. eins\n## A2\nText\n# B\n- b')).toEqual([
{ type: 'h1', content: 'A', indent: 0 },
{ type: 'h2', content: 'A1', indent: 1 },
{ type: 'numbered', content: 'eins', indent: 2 },
{ type: 'h2', content: 'A2', indent: 1 },
{ type: 'text', content: 'Text', indent: 2 },
{ type: 'h1', content: 'B', indent: 0 },
{ type: 'text', content: 'b', indent: 1 },
]);
});
+20
View File
@@ -60,3 +60,23 @@ export function monthGrid(year: number, month: number): (string | null)[][] {
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
return weeks;
}
// Workspace state for this browser tab: is the journal open, which regular note was open last
const WORKSPACE_SESSION_KEY = 'mindsift_workspace_view';
export function readWorkspaceSession(): { journal: boolean; docId: string | null } {
try {
const raw = JSON.parse(sessionStorage.getItem(WORKSPACE_SESSION_KEY) || '{}');
return { journal: raw.journal === true, docId: typeof raw.docId === 'string' ? raw.docId : null };
} catch {
return { journal: false, docId: null };
}
}
export function writeWorkspaceSession(state: { journal: boolean; docId: string | null }) {
try {
sessionStorage.setItem(WORKSPACE_SESSION_KEY, JSON.stringify(state));
} catch {
// storage blocked: the view simply starts fresh
}
}
+12 -4
View File
@@ -2,6 +2,7 @@ import type { BlockType } from './types';
// Turns pasted (Markdown) text into outline items: headings, todos, bullets, numbered items, dividers,
// fenced code and paragraphs. A blank line ends a paragraph; indentation becomes the nesting level.
// Content below a heading is nested under it (H2 below H1 one level deeper), so sections fold and zoom.
export interface PastedItem {
type: BlockType;
@@ -31,6 +32,8 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
let current: PastedItem | null = null;
let inCode = false;
let afterHeading = false;
const headings: { rank: number; level: number }[] = []; // open headings, innermost last
const sectionLevel = () => (headings.length ? headings[headings.length - 1].level + 1 : 0);
const flush = () => {
if (current) {
@@ -52,13 +55,13 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
if (rawLine.trim().startsWith('```')) {
inCode = true;
flush();
current = { type: 'text', content: rawLine, indent: 0 };
current = { type: 'text', content: rawLine, indent: sectionLevel() };
afterHeading = false;
continue;
}
const { width, rest } = leadingWidth(rawLine);
const indent = levelOf(width);
const indent = sectionLevel() + levelOf(width);
const line = rest.trimEnd();
// A blank line ends the paragraph or list item
@@ -71,13 +74,18 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
if (heading) {
flush();
afterHeading = true;
items.push({ type: (['h1', 'h2', 'h3'] as const)[heading[1].length - 1], content: heading[2], indent });
const rank = heading[1].length;
while (headings.length && headings[headings.length - 1].rank >= rank) headings.pop();
const level = sectionLevel();
headings.push({ rank, level });
items.push({ type: (['h1', 'h2', 'h3'] as const)[rank - 1], content: heading[2], indent: level });
continue;
}
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) {
flush();
afterHeading = false;
items.push({ type: 'divider', content: '---', indent });
headings.length = 0; // a divider closes the sections
items.push({ type: 'divider', content: '---', indent: 0 });
continue;
}
+15 -6
View File
@@ -740,9 +740,10 @@ func indentWidth(s string) int {
func MarkdownToBlocks(md string) []domain.DocumentBlock {
lines := strings.Split(md, "\n")
var blocks []domain.DocumentBlock
base := 0 // 1 inside a heading section
var indents []int // indent widths of the open list levels
inList := false // the previous line belonged to a list item (for continuation lines)
base := 0 // level of content in the current heading section
var headings []int // ranks (1–3) of the open headings: H2 below H1 nests one level deeper
var indents []int // indent widths of the open list levels
inList := false // the previous line belonged to a list item (for continuation lines)
listLevel := func(width int) int {
for len(indents) > 0 && indents[len(indents)-1] > width {
indents = indents[:len(indents)-1]
@@ -789,15 +790,23 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
if typ == "" {
typ = "h3"
}
blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), 0))
base, indents, inList = 1, nil, false
rank := len(m[1])
if rank > 3 {
rank = 3
}
for len(headings) > 0 && headings[len(headings)-1] >= rank {
headings = headings[:len(headings)-1]
}
blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), len(headings)))
headings = append(headings, rank)
base, indents, inList = len(headings), nil, false
continue
}
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
blocks = append(blocks, newBlock("divider", "", 0))
base, indents, inList = 0, nil, false
base, headings, indents, inList = 0, nil, nil, false
continue
}
}
+16
View File
@@ -4,6 +4,7 @@ import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
@@ -219,3 +220,18 @@ func TestImportBackupZip(t *testing.T) {
}
checkRestored(t, store, ben)
}
func TestMarkdownHeadingsNestTheirSections(t *testing.T) {
blocks := MarkdownToBlocks("# Projekt\nEinleitung\n## Teil A\n- Punkt\n - Unterpunkt\n## Teil B\n1. Erstens\n# Anhang\nText")
var got []string
for _, b := range blocks {
got = append(got, fmt.Sprintf("%d:%s:%s", b.Level, b.Type, b.Content))
}
want := []string{
"0:h1:Projekt", "1:bullet:Einleitung", "1:h2:Teil A", "2:bullet:Punkt", "3:bullet:Unterpunkt",
"1:h2:Teil B", "2:numbered:Erstens", "0:h1:Anhang", "1:bullet:Text",
}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("got %v\nwant %v", got, want)
}
}