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
6 changes: 6 additions & 0 deletions src/cmd/simplenvim/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ func main() {

a := editorapp.New(cfg, opts.NvimArgs, editorapp.Options{Maximized: opts.Maximized})

// Subscribe to "open this document" requests from the desktop
// environment before the event loop starts. On macOS the request for
// the file that caused the launch arrives during startup, so a later
// subscription would miss it and the app would open empty.
editorapp.InstallOpenFileHandler()

go func() {
win := new(gioapp.Window)
if err := a.Run(win); err != nil {
Expand Down
1 change: 1 addition & 0 deletions src/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ func (a *App) layout(gtx layout.Context) {

a.handleInput(gtx)
a.syncSize(size)
a.drainOpenRequests()

snap := a.state.Snapshot()
if snap.Title != a.title {
Expand Down
55 changes: 55 additions & 0 deletions src/internal/app/openfile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package editorapp

import "sync"

// pendingOpens holds file paths the desktop environment has asked us to
// open, until the editor is ready to act on them.
//
// A queue is required rather than a direct call, for two reasons:
//
// - The request can arrive before Nvim exists. On macOS the Apple Event
// that carries the filename is delivered during application launch,
// which is well before the first frame has run and spawned the child
// process. Dropping it there is what makes an app appear to open with
// an empty buffer.
// - It crosses threads. The platform delivers the path on the AppKit main
// thread, while Nvim is driven from Gio's event loop.
var pendingOpens = struct {
mu sync.Mutex
paths []string
}{}

// queueOpenFile records a path to be opened as soon as the editor can.
// Safe to call from any thread, at any point in the lifecycle.
func queueOpenFile(path string) {
if path == "" {
return
}
pendingOpens.mu.Lock()
defer pendingOpens.mu.Unlock()
pendingOpens.paths = append(pendingOpens.paths, path)
}

// takeQueuedOpens removes and returns every queued path.
func takeQueuedOpens() []string {
pendingOpens.mu.Lock()
defer pendingOpens.mu.Unlock()
if len(pendingOpens.paths) == 0 {
return nil
}
paths := pendingOpens.paths
pendingOpens.paths = nil
return paths
}

// drainOpenRequests opens any files the desktop environment has requested
// since the last frame. It is a no-op until Nvim is running, and the paths
// stay queued until then.
func (a *App) drainOpenRequests() {
if a.proc == nil {
return
}
for _, path := range takeQueuedOpens() {
a.proc.OpenFile(path)
}
}
30 changes: 30 additions & 0 deletions src/internal/app/openfile_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//go:build darwin

package editorapp

/*
#cgo CFLAGS: -x objective-c -fmodules -fobjc-arc
#cgo LDFLAGS: -framework Cocoa

// Declarations only. The implementation lives in openfile_darwin.m --
// this preamble is prepended to every translation unit cgo generates for
// the package, so defining the class or function here would compile them
// more than once and fail the link with duplicate symbols.
void snv_install_open_file_handler(void);
*/
import "C"

//export snv_onOpenFile
func snv_onOpenFile(path *C.char) {
queueOpenFile(C.GoString(path))
}

// InstallOpenFileHandler subscribes to Finder's "open document" events.
//
// It must be called before the app finishes launching, because the event
// for the file that *caused* the launch is delivered during startup: a
// handler installed after the first frame would miss it entirely, which
// looks exactly like the app ignoring the file it was asked to open.
func InstallOpenFileHandler() {
C.snv_install_open_file_handler()
}
83 changes: 83 additions & 0 deletions src/internal/app/openfile_darwin.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Apple Event handler for Finder's "open document" ('odoc') event.
//
// This lives in a real .m file rather than in the cgo preamble of
// openfile_darwin.go. A preamble is textually prepended to *every* C
// translation unit cgo generates for that package, so any function or ObjC
// class *defined* (not merely declared) there is compiled more than once and
// the link fails with duplicate symbols:
//
// duplicate symbol '_OBJC_CLASS_$_SNVOpenFileHandler'
// duplicate symbol '_snv_install_open_file_handler'
//
// That is guaranteed to happen once the same file also uses //export, because
// cgo then emits an extra translation unit for the exported thunks. The rule
// is: preambles declare, .m/.c files define.

#import <Cocoa/Cocoa.h>

// Implemented in Go (openfile_darwin.go, //export snv_onOpenFile).
void snv_onOpenFile(char *path);

// SNVOpenFileHandler receives the 'odoc' (kAEOpenDocuments) Apple Event,
// which is what Finder sends when a file is opened with this app.
//
// Why an Apple Event handler rather than the NSApplicationDelegate method:
// Gio owns the delegate (GioAppDelegate in os_macos.m) and implements only
// application:openURLs:, which fires for registered URL *schemes*, not for
// file opens. Replacing Gio's delegate would fight the toolkit for
// ownership and break on upgrade. Registering directly with
// NSAppleEventManager is additive and leaves Gio untouched: AppKit's
// built-in 'odoc' handler is merely what would otherwise forward to
// application:openFile:, so claiming that one event changes nothing else.
@interface SNVOpenFileHandler : NSObject
@end

@implementation SNVOpenFileHandler

- (void)handleOpenDocs:(NSAppleEventDescriptor *)event
withReplyEvent:(NSAppleEventDescriptor *)reply {
NSAppleEventDescriptor *list = [event paramDescriptorForKeyword:keyDirectObject];
if (list == nil) {
return;
}
// Apple Event descriptor lists are 1-based.
for (NSInteger i = 1; i <= [list numberOfItems]; i++) {
NSAppleEventDescriptor *item = [list descriptorAtIndex:i];
NSString *path = nil;

// Finder sends typeFileURL; older senders use an alias/FSRef,
// which coercing to typeFileURL normalises.
NSAppleEventDescriptor *urlDesc = [item coerceToDescriptorType:typeFileURL];
if (urlDesc != nil) {
NSString *s = [[NSString alloc] initWithData:[urlDesc data]
encoding:NSUTF8StringEncoding];
path = [[NSURL URLWithString:s] path];
}
if (path == nil) {
path = [item stringValue];
}
if (path != nil) {
snv_onOpenFile((char *)[path UTF8String]);
}
}
}

@end

static SNVOpenFileHandler *snvHandler = nil;

void snv_install_open_file_handler(void) {
// NSAppleEventManager is not thread-safe and must be registered
// against the main run loop.
dispatch_async(dispatch_get_main_queue(), ^{
if (snvHandler != nil) {
return;
}
snvHandler = [[SNVOpenFileHandler alloc] init];
[[NSAppleEventManager sharedAppleEventManager]
setEventHandler:snvHandler
andSelector:@selector(handleOpenDocs:withReplyEvent:)
forEventClass:kCoreEventClass
andEventID:kAEOpenDocuments];
});
}
11 changes: 11 additions & 0 deletions src/internal/app/openfile_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:build !darwin

package editorapp

// InstallOpenFileHandler is a no-op away from macOS.
//
// Linux and Windows pass the filename as an ordinary command-line
// argument (via the .desktop Exec line and the shell "Edit with" verb
// respectively), so it arrives through cli.Parse like any other argv entry
// and needs no out-of-band delivery.
func InstallOpenFileHandler() {}
126 changes: 126 additions & 0 deletions src/internal/app/openfile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package editorapp

import (
"fmt"
"sync"
"testing"
)

// resetPendingOpens clears global queue state so tests don't leak into
// each other.
func resetPendingOpens(t *testing.T) {
t.Helper()
takeQueuedOpens()
t.Cleanup(func() { takeQueuedOpens() })
}

func TestQueueOpenFileRoundTrips(t *testing.T) {
resetPendingOpens(t)

queueOpenFile("/tmp/a.txt")
queueOpenFile("/tmp/b.txt")

got := takeQueuedOpens()
want := []string{"/tmp/a.txt", "/tmp/b.txt"}
if len(got) != len(want) {
t.Fatalf("takeQueuedOpens() = %v, want %v", got, want)
}
// Order matters: opening several files should edit them in the order
// the desktop environment listed them.
for i := range want {
if got[i] != want[i] {
t.Errorf("path %d = %q, want %q", i, got[i], want[i])
}
}
}

// TestQueueOpenFileIgnoresEmpty guards the queue against a path the
// platform failed to decode: an empty string would become a bare ":edit".
func TestQueueOpenFileIgnoresEmpty(t *testing.T) {
resetPendingOpens(t)

queueOpenFile("")
if got := takeQueuedOpens(); got != nil {
t.Errorf("takeQueuedOpens() = %v, want nil", got)
}
}

// TestTakeQueuedOpensDrains verifies the queue is emptied by a read, so a
// file is opened exactly once rather than on every subsequent frame.
func TestTakeQueuedOpensDrains(t *testing.T) {
resetPendingOpens(t)

queueOpenFile("/tmp/a.txt")
if got := takeQueuedOpens(); len(got) != 1 {
t.Fatalf("first take = %v, want 1 path", got)
}
if got := takeQueuedOpens(); got != nil {
t.Errorf("second take = %v, want nil (queue should be drained)", got)
}
}

// TestQueueOpenFileIsThreadSafe exercises the reason the queue exists.
//
// The platform delivers paths on its own thread (the AppKit main thread on
// macOS) while the editor drains them from Gio's event loop. Run with
// -race, this asserts those two sides cannot corrupt the slice.
func TestQueueOpenFileIsThreadSafe(t *testing.T) {
resetPendingOpens(t)

const writers, perWriter = 8, 50

var wg sync.WaitGroup
wg.Add(writers)
for w := 0; w < writers; w++ {
go func(w int) {
defer wg.Done()
for i := 0; i < perWriter; i++ {
queueOpenFile(fmt.Sprintf("/tmp/%d-%d.txt", w, i))
}
}(w)
}

// Drain concurrently with the writers, collecting as we go.
done := make(chan int)
go func() {
seen := 0
for {
seen += len(takeQueuedOpens())
select {
case <-done:
done <- seen + len(takeQueuedOpens())
return
default:
}
}
}()

wg.Wait()
done <- 0
total := <-done

if want := writers * perWriter; total != want {
t.Errorf("collected %d paths, want %d (none may be lost or duplicated)", total, want)
}
}

// TestDrainOpenRequestsWithoutNvimKeepsPaths is the regression test for
// opening an empty editor.
//
// On macOS the Apple Event naming the file arrives during launch, before
// Nvim has been spawned. If drain discarded the queue while a.proc was
// nil, the file that caused the launch would be silently dropped -- the
// app would come up blank.
func TestDrainOpenRequestsWithoutNvimKeepsPaths(t *testing.T) {
resetPendingOpens(t)

queueOpenFile("/tmp/launch.txt")

a := &App{} // proc is nil: Nvim has not started yet
a.drainOpenRequests()

got := takeQueuedOpens()
if len(got) != 1 || got[0] != "/tmp/launch.txt" {
t.Errorf("after draining with no Nvim, queue = %v, want the path still pending", got)
}
}
Loading
Loading