Skip to content
Draft
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
257 changes: 226 additions & 31 deletions internal/adapter/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,10 +396,6 @@
}

handler.TextDocumentCodeAction = func(context *glsp.Context, params *protocol.CodeActionParams) (interface{}, error) {
if isRangeEmpty(params.Range) {
return nil, nil
}

doc, ok := server.documents.Get(params.TextDocument.URI)
if !ok {
return nil, nil
Expand All @@ -408,37 +404,43 @@

actions := []protocol.CodeAction{}

addAction := func(dir string, actionTitle string) error {
opts := cmdNewOpts{
Title: doc.ContentAtRange(params.Range),
Dir: dir,
InsertLinkAtLocation: &protocol.Location{
URI: params.TextDocument.URI,
Range: params.Range,
},
}
missingBacklinkActions := server.getMissingBacklinkCodeActions(doc, params.TextDocument.URI, params.Range)
actions = append(actions, missingBacklinkActions...)

// Only add "New note" actions if range is not empty.
if !isRangeEmpty(params.Range) {
addAction := func(dir string, actionTitle string) error {
opts := cmdNewOpts{
Title: doc.ContentAtRange(params.Range),
Dir: dir,
InsertLinkAtLocation: &protocol.Location{
URI: params.TextDocument.URI,
Range: params.Range,
},
}

var jsonOpts map[string]interface{}
err := unmarshalJSON(opts, &jsonOpts)
if err != nil {
return err
}
var jsonOpts map[string]interface{}
err := unmarshalJSON(opts, &jsonOpts)
if err != nil {
return err
}

actions = append(actions, protocol.CodeAction{
Title: actionTitle,
Kind: stringPtr(protocol.CodeActionKindRefactor),
Command: &protocol.Command{
Title: actionTitle,
Command: cmdNew,
Arguments: []interface{}{wd, jsonOpts},
},
})
actions = append(actions, protocol.CodeAction{
Title: actionTitle,
Kind: stringPtr(protocol.CodeActionKindRefactor),
Command: &protocol.Command{
Title: actionTitle,
Command: cmdNew,
Arguments: []interface{}{wd, jsonOpts},
},
})

return nil
}
return nil
}

addAction(wd, "New note in current directory")
addAction("", "New note in top directory")
addAction(wd, "New note in current directory")
addAction("", "New note in top directory")
}

return actions, nil
}
Expand Down Expand Up @@ -938,3 +940,196 @@
s := strings.ToLower(fmt.Sprint(obj))
return s == "true" || s == "1"
}

// MissingBacklink contains information of a note that links to the current note.
type MissingBacklink struct {
SourcePath string
SourceTitle string
}

// findMissingBacklinks finds notes that link to the current note but are not linked back.
func (s *Server) findMissingBacklinks(currentNoteID core.NoteID, notebook *core.Notebook, doc *document) ([]MissingBacklink, error) {
currentNote, err := notebook.FindMinimalNotes(core.NoteFindOpts{
IncludeIDs: []core.NoteID{currentNoteID},
})
if err != nil || len(currentNote) == 0 {
return nil, err
}
currentNotePath := currentNote[0].Path

notesThatLinkToUs, err := notebook.FindMinimalNotes(core.NoteFindOpts{
LinkTo: &core.LinkFilter{
Hrefs: []string{currentNotePath},
},
})
if err != nil {
return nil, err
}

if len(notesThatLinkToUs) == 0 {
return nil, nil
}

currentDocLinks, err := doc.DocumentLinks()
if err != nil {
return nil, err
}

backlinkedNoteIDs := make(map[core.NoteID]bool)
for _, link := range currentDocLinks {
if strutil.IsURL(link.Href) {
continue
}
// Resolve the link to find the target note
target, err := s.noteForLink(link, notebook)
if err != nil || target == nil {
continue
}
// Extract note ID from the URI - we need to find the note by path
targetPath, err := uriToPath(target.URI)
if err != nil {
continue
}
targetRelPath, err := notebook.RelPath(targetPath)
if err != nil {
continue
}
targetNote, err := notebook.FindByHref(targetRelPath, false)
if err != nil || targetNote == nil {
continue
}
backlinkedNoteIDs[targetNote.ID] = true
}

var missingBacklinks []MissingBacklink
for _, linkingNote := range notesThatLinkToUs {
if !backlinkedNoteIDs[linkingNote.ID] {
missingBacklinks = append(missingBacklinks, MissingBacklink{
SourcePath: linkingNote.Path,
SourceTitle: linkingNote.Title,
})
}
}

return missingBacklinks, nil
}



// getMissingBacklinkCodeActions returns code actions for adding missing backlinks.
func (s *Server) getMissingBacklinkCodeActions(doc *document, docURI protocol.DocumentUri, requestRange protocol.Range) []protocol.CodeAction {
notebook, err := s.notebookOf(doc)
if err != nil {
return nil
}

diagConfig := notebook.Config.LSP.Diagnostics
if diagConfig.MissingBacklink == core.LSPDiagnosticNone {

Check failure on line 1027 in internal/adapter/lsp/server.go

View workflow job for this annotation

GitHub Actions / build

diagConfig.MissingBacklink undefined (type "github.com/zk-org/zk/internal/core".LSPDiagnosticConfig has no field or method MissingBacklink)
return nil
}

relPath, err := notebook.RelPath(doc.Path)
if err != nil {
return nil
}

currentNote, err := notebook.FindByHref(relPath, false)
if err != nil || currentNote == nil {
return nil
}

missingBacklinks, err := s.findMissingBacklinks(currentNote.ID, notebook, doc)
if err != nil || len(missingBacklinks) == 0 {
return nil
}

var actions []protocol.CodeAction
var formattedLinks []string

lines := strings.Split(doc.Content, "\n")
currentLine := min(requestRange.End.Line, uint32(len(lines)-1))

insertPosition := protocol.Position{
Line: currentLine,
Character: uint32(len(lines[currentLine])),
}

// Format all links once and generate individual actions
for _, backlink := range missingBacklinks {
// Full note metadata is required for NewLinkFormatterContext
sourceNote, err := notebook.FindByHref(backlink.SourcePath, false)
if err != nil || sourceNote == nil {
continue
}

linkFormatter, err := notebook.NewLinkFormatter()
if err != nil {
continue
}

noteDir := filepath.Dir(doc.Path)
linkPath := core.NotebookPath{
Path: backlink.SourcePath,
BasePath: notebook.Path,
WorkingDir: noteDir,
}
linkContext, err := core.NewLinkFormatterContext(linkPath, backlink.SourceTitle, sourceNote.Metadata)
if err != nil {
continue
}

link, err := linkFormatter(linkContext)
if err != nil {
continue
}

// Store formatted link for reuse
formattedLinks = append(formattedLinks, link)

title := backlink.SourceTitle
if title == "" {
title = filepath.Base(backlink.SourcePath)
}

// Create individual action
actions = append(actions, protocol.CodeAction{
Title: fmt.Sprintf("Add backlink to %s", title),
Kind: stringPtr(protocol.CodeActionKindQuickFix),
Edit: &protocol.WorkspaceEdit{
Changes: map[protocol.DocumentUri][]protocol.TextEdit{
docURI: {{
Range: protocol.Range{
Start: insertPosition,
End: insertPosition,
},
NewText: "\n" + link,
}},
},
},
})
}

// Add "add all missing backlinks" action if there are multiple backlinks
if len(formattedLinks) > 1 {
// Join all formatted links with newlines
allLinksText := "\n" + strings.Join(formattedLinks, "\n")

actions = append(actions, protocol.CodeAction{
Title: fmt.Sprintf("Add all %d missing backlinks", len(formattedLinks)),
Kind: stringPtr(protocol.CodeActionKindQuickFix),
Edit: &protocol.WorkspaceEdit{
Changes: map[protocol.DocumentUri][]protocol.TextEdit{
docURI: {{
Range: protocol.Range{
Start: insertPosition,
End: insertPosition,
},
NewText: allLinksText,
}},
},
},
})
}

return actions
}
Loading