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
15 changes: 15 additions & 0 deletions desktop/internal/core/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 36 additions & 0 deletions desktop/internal/core/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions desktop/internal/i18n/shared.generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions desktop/internal/workbench/markdown_preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
81 changes: 78 additions & 3 deletions desktop/internal/workbench/outline.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package workbench

import (
"context"
"hash/fnv"
"sort"
"strconv"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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() {
Expand Down
Loading
Loading