diff --git a/desktop/internal/core/markdown.go b/desktop/internal/core/markdown.go index a7a70a1f2..f11c45d5a 100644 --- a/desktop/internal/core/markdown.go +++ b/desktop/internal/core/markdown.go @@ -15,6 +15,21 @@ func (c *Client) RenderMarkdown(ctx context.Context, source string) (string, err return response.HTML, err } +// MarkdownOutline outlines the headings of Markdown source in Core's shared dialect, as the +// symbol tree a language server's document symbols are normalized into: a heading is a child +// of the nearest heading before it of a lower level, its selection range is the heading and its +// range the section up to the next heading of the same or a higher level. The headings are the +// ones the preview's outline lists; front matter, code and raw HTML headings are not headings. +func (c *Client) MarkdownOutline(ctx context.Context, source string) ([]DocumentSymbol, error) { + var response struct { + Symbols []DocumentSymbol `json:"symbols"` + } + err := c.Execute(ctx, "markdown.outline", struct { + Source string `json:"source"` + }{source}, &response) + return response.Symbols, err +} + // MarkdownReferencePurpose says whether a reference is a clicked link or an image the page loads. type MarkdownReferencePurpose string diff --git a/desktop/internal/core/markdown_test.go b/desktop/internal/core/markdown_test.go index bf6607f8e..26dc6dfb5 100644 --- a/desktop/internal/core/markdown_test.go +++ b/desktop/internal/core/markdown_test.go @@ -9,6 +9,42 @@ import ( "github.com/1lck/Lithe-IDEA/desktop/internal/contractfixture" ) +func TestMarkdownOutlineSharedFixture(t *testing.T) { + data, err := os.ReadFile(contractfixture.CoreFixture("editor/markdown-outline-v1.json")) + if err != nil { + t.Fatal(err) + } + var fixture struct { + Cases []struct { + Name string + Source string + Expected struct { + Symbols []DocumentSymbol + } + } + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if len(fixture.Cases) == 0 { + t.Fatal("the outline fixture has no cases") + } + client := NewClient() + for _, c := range fixture.Cases { + t.Run(c.Name, func(t *testing.T) { + symbols, err := client.MarkdownOutline(testContext(t), c.Source) + if err != nil { + t.Fatal(err) + } + got, _ := json.Marshal(symbols) + want, _ := json.Marshal(c.Expected.Symbols) + if string(got) != string(want) { + t.Errorf("symbols\n got %s\nwant %s", got, want) + } + }) + } +} + func TestRenderMarkdownSharedFixture(t *testing.T) { data, err := os.ReadFile(contractfixture.CoreFixture("editor/markdown-render-v1.json")) if err != nil { diff --git a/desktop/internal/i18n/shared.generated.go b/desktop/internal/i18n/shared.generated.go index 4db68171c..354cb731a 100644 --- a/desktop/internal/i18n/shared.generated.go +++ b/desktop/internal/i18n/shared.generated.go @@ -968,11 +968,13 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.outline.loading": "Loading structure...", "desktop.outline.matches": "Matches: {matches}/{total}", "desktop.outline.noFile": "Open a file to see its structure.", + "desktop.outline.noHeadings": "This file has no headings.", "desktop.outline.noServerHint": "Install a plugin that supports this language to see the file's classes, functions and fields here.", "desktop.outline.serverDown": "The language server is not running, so the structure cannot be shown.", "desktop.outline.serverDownHint": "See the language server's status and log in the status bar.", "desktop.outline.sort": "Sort by name", "desktop.outline.starting": "Language server is starting…", + "desktop.outline.tooLarge": "This file is too large to show its structure.", "desktop.outline.unavailable": "No language server provides the structure of this file.", "desktop.outline.unsupported": "The language server of this file does not provide its structure.", "desktop.panel.header": "Tool window header", @@ -3467,11 +3469,13 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.outline.loading": "正在加载结构…", "desktop.outline.matches": "匹配:{matches}/{total}", "desktop.outline.noFile": "打开文件后可查看其结构。", + "desktop.outline.noHeadings": "此文件没有标题。", "desktop.outline.noServerHint": "安装支持此语言的插件后,这里会显示文件里的类、函数和字段。", "desktop.outline.serverDown": "语言服务器未在运行,无法显示结构。", "desktop.outline.serverDownHint": "可在状态栏查看语言服务器状态和日志。", "desktop.outline.sort": "按名称排序", "desktop.outline.starting": "语言服务器正在启动…", + "desktop.outline.tooLarge": "此文件过大,无法显示结构。", "desktop.outline.unavailable": "没有语言服务器为此文件提供结构。", "desktop.outline.unsupported": "此文件的语言服务器不提供结构。", "desktop.panel.header": "工具窗口标题栏", diff --git a/desktop/internal/workbench/markdown_preview.go b/desktop/internal/workbench/markdown_preview.go index b4d32c9f4..eb0b6b482 100644 --- a/desktop/internal/workbench/markdown_preview.go +++ b/desktop/internal/workbench/markdown_preview.go @@ -408,6 +408,17 @@ func (s *markdownPreviewState) jump(d *document, index int) { s.scrollTo(d, heading.start) } +// showLine puts source line (from 0) at the top of d's page, for a place chosen outside the page +// while the page is shown alone, its editor hidden. +func (s *markdownPreviewState) showLine(d *document, line int) { + if w := s.web; w != nil { + w.line = float64(line + 1) + w.entry.view.Send(markdownPageScroll{Type: "scroll", Line: w.line}) + return + } + s.scrollTo(d, d.view.TextSnapshot().LineStart(line)) +} + // paintMarkdown lays the page out for the width it has and draws it scrolled. Beside the source // it first follows the editor when the editor scrolled or the page was laid out again. func (a *App) paintMarkdown(dc *ui.DrawContext, d *document, s *markdownPreviewState) { diff --git a/desktop/internal/workbench/outline.go b/desktop/internal/workbench/outline.go index e04bb4c16..cc37159c2 100644 --- a/desktop/internal/workbench/outline.go +++ b/desktop/internal/workbench/outline.go @@ -1,6 +1,7 @@ package workbench import ( + "context" "hash/fnv" "sort" "strconv" @@ -23,6 +24,9 @@ import ( // or lacks document symbols is read from the session state on every frame and shown as a final // state; none of those states asks for frames, because each way out of them (a server's state // event, the end of its start, the end of the document's synchronization) wakes the window. +// +// A Markdown file needs no server: Core outlines its headings in the dialect the preview renders, +// so the window lists the headings the preview's outline lists, whatever plugins are installed. const ( // outlineDelay is how long after the last request the next one waits, so typing in a @@ -47,8 +51,22 @@ const ( outlineServerDown // outlineUnsupported is a server that does not offer document symbols. outlineUnsupported + // outlineMarkdown is a Markdown file: ask Core for the outline of its headings. + outlineMarkdown ) +// markdownOutlineKernel is what the Structure window asks Core for a Markdown file: its headings +// as a symbol tree. +type markdownOutlineKernel interface { + MarkdownOutline(ctx context.Context, source string) ([]core.DocumentSymbol, error) +} + +// outlineMarkdownRequest is one request for a Markdown file's headings; the state holds the one +// under way, so an answer to any other is stale. +type outlineMarkdownRequest struct { + revision int +} + // outlineRow is one visible row of the tree. type outlineRow struct { symbol *core.DocumentSymbol @@ -70,8 +88,12 @@ type outlineState struct { requested, revision int requestedAt time.Time pending *semanticPending - // loaded says symbols holds an answer; failed says the last request failed. - loaded, failed bool + // outlining is the request for a Markdown file's headings under way, nil for none. + outlining *outlineMarkdownRequest + // loaded says symbols holds an answer; failed says the last request failed; tooLarge says + // the Markdown file is over markdownPreviewMaxBytes and is not outlined, as it is not + // previewed. + loaded, failed, tooLarge bool // availability is what the last sync found about the file's server. availability outlineAvailability symbols []core.DocumentSymbol @@ -113,8 +135,12 @@ func (a *App) outlineReset(p *project, documentID int) { } } -// outlineAvailabilityOf classifies the server of the selected document. +// outlineAvailabilityOf classifies the server of the selected document. A Markdown file is +// Core's to outline. func (a *App) outlineAvailabilityOf(d *document) outlineAvailability { + if _, ok := a.kernel.(markdownOutlineKernel); ok && isMarkdownDocument(d) { + return outlineMarkdown + } if a.plugins == nil || d.virtual || d.languageID == "" { return outlineNoServer } @@ -152,6 +178,9 @@ func (a *App) outlineSync(p *project, d *document, now time.Time) (retryIn time. s.pending = nil } s.availability = a.outlineAvailabilityOf(d) + if s.availability == outlineMarkdown { + return a.outlineMarkdownSync(p, d, revision, now) + } if s.pending != nil || s.requested == revision || s.availability != outlineAsk { return 0 } @@ -167,6 +196,52 @@ func (a *App) outlineSync(p *project, d *document, now time.Time) (retryIn time. return 0 } +// outlineMarkdownSync asks Core for the headings of a Markdown document when its text changed +// since the last request and no request is under way, spaced like a server's requests. A file +// over markdownPreviewMaxBytes is not sent. +func (a *App) outlineMarkdownSync(p *project, d *document, revision int, now time.Time) (retryIn time.Duration) { + s := &a.editor.outline + kernel, ok := a.kernel.(markdownOutlineKernel) + if !ok || s.outlining != nil || s.requested == revision { + return 0 + } + if wait := s.requestedAt.Add(outlineDelay).Sub(now); wait > 0 { + return wait + } + s.requested, s.requestedAt, s.failed = revision, now, false + text := d.view.TextSnapshot() + if s.tooLarge = text.Len() > markdownPreviewMaxBytes; s.tooLarge { + s.symbols, s.loaded, s.total, s.rowsDirty = nil, false, 0, true + return 0 + } + request := &outlineMarkdownRequest{revision: revision} + s.outlining = request + source := text.String() + a.background(p, markdownRenderTimeout, func(ctx context.Context) func(*App) { + symbols, err := kernel.MarkdownOutline(ctx, source) + return func(a *App) { a.applyMarkdownOutline(request, symbols, err) } + }) + return 0 +} + +// applyMarkdownOutline takes Core's outline of a Markdown document. The answer to a request that +// is no longer under way, because another document was selected since, is dropped. +func (a *App) applyMarkdownOutline(request *outlineMarkdownRequest, symbols []core.DocumentSymbol, err error) { + s := &a.editor.outline + if s.outlining != request { + return + } + s.outlining = nil + if err != nil { + a.emit(operationFailedLevel, operationFailedEvent, operationFailedFields("markdown_outline", err)) + s.failed = true + return + } + s.symbols, s.revision, s.loaded, s.failed = symbols, request.revision, true, false + s.total = countSymbols(symbols) + s.rowsDirty, s.follow = true, true +} + // retry asks again for the symbols of the same text after a request failed, at once: the // delay between requests spaces out typing, not an explicit retry. func (s *outlineState) retry() { diff --git a/desktop/internal/workbench/outline_markdown_test.go b/desktop/internal/workbench/outline_markdown_test.go new file mode 100644 index 000000000..162809a7a --- /dev/null +++ b/desktop/internal/workbench/outline_markdown_test.go @@ -0,0 +1,310 @@ +package workbench + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + + "github.com/1lck/Lithe-IDEA/desktop/internal/core" + "github.com/1lck/Lithe-IDEA/desktop/internal/i18n" +) + +// outlineKernel answers markdown.outline through Core and counts the requests. It can fail them, +// or hold them until release, so a test decides when an answer arrives. +type outlineKernel struct { + *testKernel + mu sync.Mutex + calls int + err error + held chan struct{} +} + +func newOutlineKernel() *outlineKernel { + return &outlineKernel{testKernel: &testKernel{Client: core.NewClient()}} +} + +func (k *outlineKernel) MarkdownOutline(ctx context.Context, source string) ([]core.DocumentSymbol, error) { + k.mu.Lock() + k.calls++ + err, held := k.err, k.held + k.mu.Unlock() + if held != nil { + select { + case <-held: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if err != nil { + return nil, err + } + return k.testKernel.MarkdownOutline(ctx, source) +} + +func (k *outlineKernel) requests() int { + k.mu.Lock() + defer k.mu.Unlock() + return k.calls +} + +func (k *outlineKernel) fail(err error) { + k.mu.Lock() + defer k.mu.Unlock() + k.err = err +} + +// hold keeps the next answers back until the returned release runs; release may run twice. +func (k *outlineKernel) hold() (release func()) { + held := make(chan struct{}) + k.mu.Lock() + k.held = held + k.mu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + k.mu.Lock() + k.held = nil + k.mu.Unlock() + close(held) + }) + } +} + +// openMarkdownFiles opens a project holding each named Markdown text, opens each file in order +// and returns their documents, the last one selected. +func openMarkdownFiles(t *testing.T, h *harness, files ...[2]string) []*document { + t.Helper() + root := t.TempDir() + for _, file := range files { + if err := os.WriteFile(filepath.Join(root, file[0]), []byte(file[1]), 0o600); err != nil { + t.Fatal(err) + } + } + h.open(root) + documents := make([]*document, 0, len(files)) + for _, file := range files { + path := filepath.Join(root, file[0]) + h.app.openFile(h.app.project, path) + h.settle() + d := h.app.project.docs.byPath(path) + if d == nil { + t.Fatalf("%s did not open", file[0]) + } + documents = append(documents, d) + } + return documents +} + +// showStructure selects d's tab, opens the Structure tool window and paints frames until the +// outline of d has been asked for and, unless it is held back, answered. Opening the window +// through the reducer repaints nothing by itself, so the window is invalidated here rather than +// left to whatever else repaints it. +func showStructure(t *testing.T, h *harness, d *document) { + t.Helper() + h.app.selectDocument(h.app.project, d.id) + h.app.reduceChrome(core.SetChromeOpen(itemStructure, true)) + h.app.invalidator.Invalidate() + h.settle() + h.settle() +} + +// outlineNames lists the rows of the Structure tree, each indented by two spaces a level. +func outlineNames(s *outlineState) []string { + names := make([]string, len(s.rows)) + for i, row := range s.rows { + names[i] = strings.Repeat(" ", row.depth) + row.symbol.Name + } + return names +} + +const outlineGuide = "# Guide\n\nIntro\n\n## Install\n\nSteps\n\n### macOS\n\n## Use\n" + +// TestMarkdownOutlineShowsHeadingsWithoutServer: a Markdown file, which no language server +// serves, shows its headings nested by level; the caret's heading is selected, and going to one +// puts the caret on it. +func TestMarkdownOutlineShowsHeadingsWithoutServer(t *testing.T) { + h := newHarness(t, &testKernel{Client: core.NewClient()}) + d := openMarkdownFiles(t, h, [2]string{"notes.md", outlineGuide})[0] + d.view.SetSelection(0, 0) + showStructure(t, h, d) + s := &h.app.editor.outline + if s.availability != outlineMarkdown || !s.loaded || s.total != 4 { + t.Fatalf("availability = %d, loaded = %v, total = %d", s.availability, s.loaded, s.total) + } + want := []string{"Guide", " Install", " macOS", " Use"} + if got := outlineNames(s); !slices.Equal(got, want) { + t.Fatalf("rows = %q, want %q", got, want) + } + language := h.app.language() + if !h.shows("macOS") || h.showsWrapped(i18n.Shared(language, "desktop.outline.unavailable")) { + t.Fatalf("the headings are not shown: %v", h.driver.Snapshot().Labels) + } + + // The caret on the line under "### macOS" is in that heading's section. + offset := d.view.TextSnapshot().LineStart(9) + d.view.SetSelection(offset, offset) + h.app.invalidator.Invalidate() + h.settle() + if s.selected != 2 { + t.Fatalf("selected row = %d, want macOS's", s.selected) + } + + h.app.outlineGo(h.app.project, d, s.rows[3].symbol) + h.settle() + if line, column := d.view.CaretUTF16(); line != 10 || column != 0 { + t.Fatalf("caret = %d:%d, want Use's heading at 10:0", line, column) + } +} + +// TestMarkdownOutlineFollowsEditsAfterTheDelay: an edit is outlined again, once the delay since +// the last request has passed. +func TestMarkdownOutlineFollowsEditsAfterTheDelay(t *testing.T) { + kernel := newOutlineKernel() + h := newHarness(t, kernel) + d := openMarkdownFiles(t, h, [2]string{"notes.md", outlineGuide})[0] + showStructure(t, h, d) + if got := kernel.requests(); got != 1 { + t.Fatalf("requests = %d, want 1", got) + } + h.app.post(func(a *App) { a.insertForTest(d, "\n## Added\n") }) + h.settle() + h.settle() + if got := kernel.requests(); got != 1 { + t.Fatalf("requests before the delay passed = %d, want 1", got) + } + if err := h.driver.Advance(outlineDelay + outlineDelay/2); err != nil { + t.Fatal(err) + } + h.settle() + s := &h.app.editor.outline + if got := kernel.requests(); got != 2 || s.revision != d.view.Revision() { + t.Fatalf("requests = %d, outline revision = %d, text revision = %d", got, s.revision, d.view.Revision()) + } + want := []string{"Guide", " Install", " macOS", " Use", " Added"} + if got := outlineNames(s); !slices.Equal(got, want) { + t.Fatalf("rows = %q, want %q", got, want) + } +} + +// TestMarkdownOutlineSaysWhenThereAreNoHeadings: a Markdown file without headings says so instead +// of naming symbols. +func TestMarkdownOutlineSaysWhenThereAreNoHeadings(t *testing.T) { + h := newHarness(t, &testKernel{Client: core.NewClient()}) + d := openMarkdownFiles(t, h, [2]string{"notes.md", "Just a paragraph.\n\n```\n# not a heading\n```\n"})[0] + showStructure(t, h, d) + language := h.app.language() + if s := &h.app.editor.outline; !s.loaded || len(s.symbols) != 0 { + t.Fatalf("loaded = %v, symbols = %+v", s.loaded, s.symbols) + } + if !h.showsWrapped(i18n.Shared(language, "desktop.outline.noHeadings")) || h.showsWrapped(i18n.Shared(language, "desktop.outline.empty")) { + t.Fatalf("the no-headings notice is missing: %v", h.driver.Snapshot().Labels) + } +} + +// TestMarkdownOutlineSkipsTooLargeFile: a Markdown file over the preview's limit is not sent to +// Core, and the window says why there is no structure. +func TestMarkdownOutlineSkipsTooLargeFile(t *testing.T) { + kernel := newOutlineKernel() + h := newHarness(t, kernel) + text := "# Big\n\n" + strings.Repeat("A line of text in a big file.\n", markdownPreviewMaxBytes/30+1) + d := openMarkdownFiles(t, h, [2]string{"big.md", text})[0] + showStructure(t, h, d) + s := &h.app.editor.outline + if got := kernel.requests(); got != 0 || !s.tooLarge || s.loaded { + t.Fatalf("requests = %d, tooLarge = %v, loaded = %v", got, s.tooLarge, s.loaded) + } + if !h.showsWrapped(i18n.Shared(h.app.language(), "desktop.outline.tooLarge")) { + t.Fatalf("the too-large notice is missing: %v", h.driver.Snapshot().Labels) + } +} + +// TestMarkdownOutlineFailureOffersRetry: a failed outline is shown with Retry and not asked +// again for the same text by itself; Retry asks again at once. +func TestMarkdownOutlineFailureOffersRetry(t *testing.T) { + kernel := newOutlineKernel() + kernel.fail(errors.New("boom")) + h := newHarness(t, kernel) + d := openMarkdownFiles(t, h, [2]string{"notes.md", outlineGuide})[0] + showStructure(t, h, d) + language := h.app.language() + s := &h.app.editor.outline + if !s.failed || !h.showsWrapped(i18n.Shared(language, "desktop.outline.failed")) { + t.Fatalf("failed = %v, labels = %v", s.failed, h.driver.Snapshot().Labels) + } + h.app.invalidator.Invalidate() + h.settle() + if got := kernel.requests(); got != 1 { + t.Fatalf("requests after the failure = %d, want 1", got) + } + kernel.fail(nil) + h.click(center(h.hitLabeled(i18n.Shared(language, "ui.retry")))) + h.settleUntil("the outline after Retry", func() bool { return s.loaded }) + if got := kernel.requests(); got != 2 || s.failed || !h.shows("Install") { + t.Fatalf("requests = %d, failed = %v, labels = %v", got, s.failed, h.driver.Snapshot().Labels) + } +} + +// TestMarkdownOutlineDropsTheAnswerForAnotherFile: the order is the outline of a.md asked for +// and held, b.md selected and asked for, then both answers released; only b.md's is shown. +func TestMarkdownOutlineDropsTheAnswerForAnotherFile(t *testing.T) { + kernel := newOutlineKernel() + h := newHarness(t, kernel) + documents := openMarkdownFiles(t, h, [2]string{"a.md", "# Alpha\n"}, [2]string{"b.md", "# Beta\n"}) + a, b := documents[0], documents[1] + release := kernel.hold() + // The harness waits for every task when the test ends: the held answers must come first. + t.Cleanup(release) + pump := func() { + t.Helper() + h.app.invalidator.Invalidate() + if err := h.driver.Pump(); err != nil { + t.Fatal(err) + } + } + h.app.selectDocument(h.app.project, a.id) + h.app.reduceChrome(core.SetChromeOpen(itemStructure, true)) + pump() + s := &h.app.editor.outline + if s.documentID != a.id || s.outlining == nil { + t.Fatalf("a.md is not being outlined: document = %d, outlining = %v", s.documentID, s.outlining) + } + h.app.selectDocument(h.app.project, b.id) + pump() + if s.documentID != b.id || s.outlining == nil { + t.Fatalf("b.md is not being outlined: document = %d, outlining = %v", s.documentID, s.outlining) + } + release() + h.settleUntil("b.md's outline", func() bool { return s.loaded }) + if got := outlineNames(s); s.documentID != b.id || !slices.Equal(got, []string{"Beta"}) { + t.Fatalf("document = %d, rows = %q, want b.md's Beta", s.documentID, got) + } + if got := kernel.requests(); got != 2 { + t.Fatalf("requests = %d, want 2", got) + } +} + +// TestMarkdownOutlineMovesThePageShownAlone: going to a heading while the page is shown without +// its source scrolls the page to the heading. +func TestMarkdownOutlineMovesThePageShownAlone(t *testing.T) { + h := newHarness(t, &testKernel{Client: core.NewClient()}) + d := openMarkdown(t, h, longMarkdown()) + h.click(center(h.hitLabeled(i18n.Shared(h.app.language(), "editor.markdownPreviewView")))) + if d.pageMode != pageOnly { + t.Fatalf("page mode = %d, want the page alone", d.pageMode) + } + showStructure(t, h, d) + s := &h.app.editor.outline + if !s.loaded || len(s.rows) != 40 || s.rows[11].symbol.Name != "Part 12" { + t.Fatalf("loaded = %v, availability = %d, requested = %d, failed = %v, outlining = %v, doc = %d/%d, rows = %q", s.loaded, s.availability, s.requested, s.failed, s.outlining, s.documentID, d.id, outlineNames(s)) + } + h.app.outlineGo(h.app.project, d, s.rows[11].symbol) + h.settle() + if line := pageTopLine(d.markdown); line != 55 { + t.Fatalf("the page has line %d at its top, want Part 12's line 55", line) + } +} diff --git a/desktop/internal/workbench/outline_view.go b/desktop/internal/workbench/outline_view.go index 879170b00..cea03d191 100644 --- a/desktop/internal/workbench/outline_view.go +++ b/desktop/internal/workbench/outline_view.go @@ -142,7 +142,11 @@ func (a *App) outlinePanel(c ui.C, p *project) { return } if len(s.symbols) == 0 { - a.outlineNotice(c, i18n.Shared(language, "desktop.outline.empty"), "", nil) + empty := i18n.Shared(language, "desktop.outline.empty") + if s.availability == outlineMarkdown { + empty = i18n.Shared(language, "desktop.outline.noHeadings") + } + a.outlineNotice(c, empty, "", nil) return } if s.view.Node == nil { @@ -187,12 +191,14 @@ func (a *App) outlinePanel(c ui.C, p *project) { } // outlineStatus declares why the symbols are not shown yet: no server serves the file, it is -// starting or not running, it lacks document symbols, the request failed, or the answer is on -// its way. +// starting or not running, it lacks document symbols, a Markdown file is too large, the request +// failed, or the answer is on its way. func (a *App) outlineStatus(c ui.C, s *outlineState) { language := a.language() switch { - case s.failed && s.availability == outlineAsk: + case s.availability == outlineMarkdown && s.tooLarge: + a.outlineNotice(c, i18n.Shared(language, "desktop.outline.tooLarge"), "", nil) + case s.failed && (s.availability == outlineAsk || s.availability == outlineMarkdown): a.outlineNotice(c, i18n.Shared(language, "desktop.outline.failed"), "", func() { if ui.Button(c, i18n.Shared(language, "ui.retry")).Outline().Small().Clicked() { c.After(func() { @@ -297,11 +303,16 @@ func (a *App) followOutlineCaret(c ui.C, d *document) { } } -// outlineGo puts the caret on a symbol's name and gives the keyboard back to the editor. +// outlineGo puts the caret on a symbol's name and gives the keyboard back to the editor; a +// Markdown page shown alone also scrolls the heading to its top. func (a *App) outlineGo(p *project, d *document, symbol *core.DocumentSymbol) { start := symbol.SelectionRange.Start a.openFileAtPosition(p, d.path, start.Line, start.UTF16Column) a.focusDocument(d.id) + // The editor of a page shown alone is hidden, so the page is what has to move. + if s := d.markdown; s != nil && d.pageMode == pageOnly { + s.showLine(d, start.Line) + } } // outlineRowContent declares what a row shows after its chevron: the kind's badge, the name and diff --git a/scripts/desktop/build-desktop-core.sh b/scripts/desktop/build-desktop-core.sh index bdba07a9b..88466b3df 100755 --- a/scripts/desktop/build-desktop-core.sh +++ b/scripts/desktop/build-desktop-core.sh @@ -28,7 +28,9 @@ native_path() { } PINNED_CORE_REPOSITORY="https://github.com/Lithe-IDEA/Lithe-core-rust.git" -# Lithe-core-rust #40, squash-merged into main: the update.delta.cleanup_failed event, which the +# Lithe-core-rust #41 (branch feat/markdown-outline, not yet merged): markdown.outline outlines +# the headings of Markdown source as a document-symbol tree for the Structure tool window. It is +# on top of #40, squash-merged into main: the update.delta.cleanup_failed event, which the # desktop logs when it cannot remove the application a delta update rebuilt in the temporary # directory. It is on top of #39, squash-merged into main: plugins.installed.lifecycle keeps one # enabled plugin per language server language; installed versions carry languageServers, and @@ -77,7 +79,7 @@ PINNED_CORE_REPOSITORY="https://github.com/Lithe-IDEA/Lithe-core-rust.git" # the contract, plugin guides, reference and SDKs live only in shared/plugins; on top of #16 (reported Git queries folded in the console), # #15 (git.write cherryPick, revert and reset fields), #14 (word-level changed ranges of diff # rows), #13, #8, #7, #6 and #4. -PINNED_CORE_REVISION="c1df34b4d61f22390f674cfd0f963bf6090f496e" +PINNED_CORE_REVISION="b0e19d5d03e6ff438c4476ceb2bbfb238e782bb3" PROFILE="debug" while [[ $# -gt 0 ]]; do case "$1" in diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 251477140..57b424f4e 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -1276,6 +1276,8 @@ "desktop.outline.noServerHint": "Install a plugin that supports this language to see the file's classes, functions and fields here.", "desktop.outline.unsupported": "The language server of this file does not provide its structure.", "desktop.outline.failed": "Could not get the structure of this file.", + "desktop.outline.noHeadings": "This file has no headings.", + "desktop.outline.tooLarge": "This file is too large to show its structure.", "desktop.outline.filter": "Filter symbols", "desktop.outline.follow": "Select the symbol at the caret", "desktop.outline.matches": "Matches: {matches}/{total}", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 67bbb78b0..8b921995e 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -1276,6 +1276,8 @@ "desktop.outline.noServerHint": "安装支持此语言的插件后,这里会显示文件里的类、函数和字段。", "desktop.outline.unsupported": "此文件的语言服务器不提供结构。", "desktop.outline.failed": "无法获取此文件的结构。", + "desktop.outline.noHeadings": "此文件没有标题。", + "desktop.outline.tooLarge": "此文件过大,无法显示结构。", "desktop.outline.filter": "筛选符号", "desktop.outline.follow": "选中光标所在的符号", "desktop.outline.matches": "匹配:{matches}/{total}", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index c5f453f88..89a221898 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -12955,6 +12955,20 @@ "desktop" ] }, + { + "id": "desktop.outline.noHeadings", + "description": "Structure tool window text when a Markdown file has no headings", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.outline.tooLarge", + "description": "Structure tool window text when a Markdown file is too large to outline", + "platforms": [ + "desktop" + ] + }, { "id": "desktop.outline.filter", "description": "Label and placeholder of the Structure window's filter field", diff --git a/shared/platform-feature-matrix/features/outline-symbols.json b/shared/platform-feature-matrix/features/outline-symbols.json index 62f116e3b..c715cf148 100644 --- a/shared/platform-feature-matrix/features/outline-symbols.json +++ b/shared/platform-feature-matrix/features/outline-symbols.json @@ -11,7 +11,11 @@ "desktop/internal/core/plugin_symbols_test.go", "desktop/internal/workbench/outline.go", "desktop/internal/workbench/outline_view.go", - "desktop/internal/workbench/outline_test.go" + "desktop/internal/workbench/outline_test.go", + "desktop/internal/core/markdown.go", + "desktop/internal/core/markdown_test.go", + "desktop/internal/workbench/markdown_preview.go", + "desktop/internal/workbench/outline_markdown_test.go" ] }, "windows": { @@ -22,11 +26,15 @@ "desktop/internal/core/plugin_symbols_test.go", "desktop/internal/workbench/outline.go", "desktop/internal/workbench/outline_view.go", - "desktop/internal/workbench/outline_test.go" + "desktop/internal/workbench/outline_test.go", + "desktop/internal/core/markdown.go", + "desktop/internal/core/markdown_test.go", + "desktop/internal/workbench/markdown_preview.go", + "desktop/internal/workbench/outline_markdown_test.go" ] }, "owner": "Language Tooling", - "verification": "用测试内联的合成语言服务器插件服务的文件验证符号树、展开折叠、排序和跳转;确认 macOS 是否提供同等独立 Outline 入口。 无头回归覆盖:只请求一次并跟随光标、排序与折叠、筛选保留祖先和数量、选中项跟随符号、全部折叠/展开、无服务器/启动中/不支持/请求失败与重试的说明;运行 scripts/desktop/desktop-go.sh test ./internal/workbench -run 'TestOutline' -count=1。真实语言服务器与 Windows 实机仍待验收。", + "verification": "用测试内联的合成语言服务器插件服务的文件验证符号树、展开折叠、排序和跳转;确认 macOS 是否提供同等独立 Outline 入口。 无头回归覆盖:只请求一次并跟随光标、排序与折叠、筛选保留祖先和数量、选中项跟随符号、全部折叠/展开、无服务器/启动中/不支持/请求失败与重试的说明;Markdown 文件不依赖语言服务器,由 Core markdown.outline 提供标题大纲,回归覆盖标题层级、跟随光标、编辑后延迟刷新、没有标题、文件过大、失败重试、切换文件丢弃过期结果、仅预览模式点击标题滚动页面;运行 scripts/desktop/desktop-go.sh test ./internal/workbench -run 'TestOutline|TestMarkdownOutline' -count=1,以及 scripts/desktop/desktop-go.sh test ./internal/core -run TestMarkdownOutlineSharedFixture -count=1。真实语言服务器与 Windows 实机仍待验收。", "legacy": { "revision": "5a54c41aa37a4e6e6b68918a4b05765b9dd07a36", "macos": { @@ -45,5 +53,5 @@ "verificationStatus": "pending" } }, - "notes": "Viva:Structure 工具窗口显示当前文件的 Core documentSymbols 符号树(层级形式与平铺形式统一为树),跟随光标选中最内层符号,点击或回车跳到符号名,可折叠与按名称排序;仅在窗口打开且文档与服务器同步后按文本变化节流请求。服务器未声明 documentSymbols 时说明无法提供。 2026-11-14:结构窗口按语言服务器状态说明为什么没有符号(没有服务器、启动中、已停止、不支持 documentSymbols、请求失败时可重试),工具栏可按名称筛选(保留匹配项的祖先并显示数量)、切换排序、全部折叠/展开,选中项跟随光标所在符号。" + "notes": "Viva:Structure 工具窗口显示当前文件的 Core documentSymbols 符号树(层级形式与平铺形式统一为树),跟随光标选中最内层符号,点击或回车跳到符号名,可折叠与按名称排序;仅在窗口打开且文档与服务器同步后按文本变化节流请求。服务器未声明 documentSymbols 时说明无法提供。 2026-11-14:结构窗口按语言服务器状态说明为什么没有符号(没有服务器、启动中、已停止、不支持 documentSymbols、请求失败时可重试),工具栏可按名称筛选(保留匹配项的祖先并显示数量)、切换排序、全部折叠/展开,选中项跟随光标所在符号。 2026-11-14:Markdown 文件的结构窗口不再提示“没有语言服务器”,改由 Core markdown.outline 按预览同一方言列出标题树(一级、二级标题等逐级嵌套,与预览浮动目录一致),点击标题跳到源码;仅预览模式下点击标题滚动预览页面;没有标题、文件超过预览大小上限、请求失败时分别说明,失败可重试。" }