gopls/internal/lsp: add quick-fixes to manage the go.work file

Update OrphanedFileDiagnostics to provide suggested fixes for
diagnostics related to modules that are not activated by a relevant
go.work file.

Also remove the Snapshot.openFiles method, which was completely
redundant with Snapshot.overlays.

Fixes golang/go#53880

Change-Id: I7e7aed97fb0b93415fe3dc383b6daea15241f31b
Reviewed-on: https://go-review.googlesource.com/c/tools/+/494738
Reviewed-by: Mouffull <dickmouth8340@gmail.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
Run-TryBot: Robert Findley <rfindley@google.com>
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/gopls/doc/commands.md b/gopls/doc/commands.md
index be031e9..8fe677b 100644
--- a/gopls/doc/commands.md
+++ b/gopls/doc/commands.md
@@ -289,6 +289,21 @@
 }
 ```
 
+### **run `go work [args...]`, and apply the resulting go.work**
+Identifier: `gopls.run_go_work_command`
+
+edits to the current go.work file.
+
+Args:
+
+```
+{
+	"ViewID": string,
+	"InitFirst": bool,
+	"Args": []string,
+}
+```
+
 ### **Run govulncheck.**
 Identifier: `gopls.run_govulncheck`
 
diff --git a/gopls/internal/lsp/cache/load.go b/gopls/internal/lsp/cache/load.go
index 6f60c3b..521dc1e 100644
--- a/gopls/internal/lsp/cache/load.go
+++ b/gopls/internal/lsp/cache/load.go
@@ -354,7 +354,7 @@
 
 	// Apply diagnostics about the workspace configuration to relevant open
 	// files.
-	openFiles := s.openFiles()
+	openFiles := s.overlays()
 
 	// If the snapshot does not have a valid build configuration, it may be
 	// that the user has opened a directory that contains multiple modules.
diff --git a/gopls/internal/lsp/cache/session.go b/gopls/internal/lsp/cache/session.go
index 17f9bdb..eaad67c 100644
--- a/gopls/internal/lsp/cache/session.go
+++ b/gopls/internal/lsp/cache/session.go
@@ -46,6 +46,11 @@
 func (s *Session) ID() string     { return s.id }
 func (s *Session) String() string { return s.id }
 
+// GoCommandRunner returns the gocommand Runner for this session.
+func (s *Session) GoCommandRunner() *gocommand.Runner {
+	return s.gocmdRunner
+}
+
 // Options returns a copy of the SessionOptions for this session.
 func (s *Session) Options() *source.Options {
 	s.optionsMu.Lock()
@@ -113,7 +118,8 @@
 		return nil, nil, func() {}, err
 	}
 
-	wsModFiles, wsModFilesErr := computeWorkspaceModFiles(ctx, info.gomod, info.effectiveGOWORK(), info.effectiveGO111MODULE(), s)
+	gowork, _ := info.GOWORK()
+	wsModFiles, wsModFilesErr := computeWorkspaceModFiles(ctx, info.gomod, gowork, info.effectiveGO111MODULE(), s)
 
 	// We want a true background context and not a detached context here
 	// the spans need to be unrelated and no tag values should pollute it.
@@ -199,8 +205,8 @@
 	return v, snapshot, snapshot.Acquire(), nil
 }
 
-// View returns a view with a matching name, if the session has one.
-func (s *Session) View(name string) *View {
+// ViewByName returns a view with a matching name, if the session has one.
+func (s *Session) ViewByName(name string) *View {
 	s.viewMu.Lock()
 	defer s.viewMu.Unlock()
 	for _, view := range s.views {
@@ -211,6 +217,18 @@
 	return nil
 }
 
+// View returns the view with a matching id, if present.
+func (s *Session) View(id string) (*View, error) {
+	s.viewMu.Lock()
+	defer s.viewMu.Unlock()
+	for _, view := range s.views {
+		if view.ID() == id {
+			return view, nil
+		}
+	}
+	return nil, fmt.Errorf("no view with ID %q", id)
+}
+
 // ViewOf returns a view corresponding to the given URI.
 // If the file is not already associated with a view, pick one using some heuristics.
 func (s *Session) ViewOf(uri span.URI) (*View, error) {
diff --git a/gopls/internal/lsp/cache/snapshot.go b/gopls/internal/lsp/cache/snapshot.go
index 2035364..45a32a1 100644
--- a/gopls/internal/lsp/cache/snapshot.go
+++ b/gopls/internal/lsp/cache/snapshot.go
@@ -31,6 +31,7 @@
 	"golang.org/x/tools/go/packages"
 	"golang.org/x/tools/go/types/objectpath"
 	"golang.org/x/tools/gopls/internal/bug"
+	"golang.org/x/tools/gopls/internal/lsp/command"
 	"golang.org/x/tools/gopls/internal/lsp/filecache"
 	"golang.org/x/tools/gopls/internal/lsp/protocol"
 	"golang.org/x/tools/gopls/internal/lsp/source"
@@ -191,6 +192,16 @@
 	// detect ignored files.
 	ignoreFilterOnce sync.Once
 	ignoreFilter     *ignoreFilter
+
+	// If non-nil, the result of computing orphaned file diagnostics.
+	//
+	// Only the field, not the map itself, is guarded by the mutex. The map must
+	// not be mutated.
+	//
+	// Used to save work across diagnostics+code action passes.
+	// TODO(rfindley): refactor all of this so there's no need to re-evaluate
+	// diagnostics during code-action.
+	orphanedFileDiagnostics map[span.URI]*source.Diagnostic
 }
 
 var globalSnapshotID uint64
@@ -293,7 +304,8 @@
 }
 
 func (s *snapshot) WorkFile() span.URI {
-	return s.view.effectiveGOWORK()
+	gowork, _ := s.view.GOWORK()
+	return gowork
 }
 
 func (s *snapshot) Templates() map[span.URI]source.FileHandle {
@@ -544,7 +556,7 @@
 	// the main (workspace) module. Otherwise, we should use the module for
 	// the passed-in working dir.
 	if mode == source.LoadWorkspace {
-		if s.view.effectiveGOWORK() == "" && s.view.gomod != "" {
+		if gowork, _ := s.view.GOWORK(); gowork == "" && s.view.gomod != "" {
 			modURI = s.view.gomod
 		}
 	} else {
@@ -929,7 +941,7 @@
 	}
 
 	// If GOWORK is outside the folder, ensure we are watching it.
-	gowork := s.view.effectiveGOWORK()
+	gowork, _ := s.view.GOWORK()
 	if gowork != "" && !source.InDir(s.view.folder.Filename(), gowork.Filename()) {
 		patterns[gowork.Filename()] = struct{}{}
 	}
@@ -1351,19 +1363,6 @@
 	return open
 }
 
-func (s *snapshot) openFiles() []*Overlay {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	var open []*Overlay
-	s.files.Range(func(uri span.URI, fh source.FileHandle) {
-		if o, ok := fh.(*Overlay); ok {
-			open = append(open, o)
-		}
-	})
-	return open
-}
-
 func isFileOpen(fh source.FileHandle) bool {
 	_, open := fh.(*Overlay)
 	return open
@@ -1588,7 +1587,7 @@
 	// that exist only in overlays. As a workaround, we search all of the files
 	// available in the snapshot and reload their metadata individually using a
 	// file= query if the metadata is unavailable.
-	open := s.openFiles()
+	open := s.overlays()
 	var files []*Overlay
 	for _, o := range open {
 		uri := o.URI()
@@ -1686,10 +1685,26 @@
 // TODO(rfindley): reconcile the definition of "orphaned" here with
 // reloadOrphanedFiles. The latter does not include files with
 // command-line-arguments packages.
-func (s *snapshot) OrphanedFileDiagnostics(ctx context.Context) map[span.URI]*source.Diagnostic {
+func (s *snapshot) OrphanedFileDiagnostics(ctx context.Context) (map[span.URI]*source.Diagnostic, error) {
+	// Orphaned file diagnostics are queried from code actions to produce
+	// quick-fixes (and may be queried many times, once for each file).
+	//
+	// Because they are non-trivial to compute, record them optimistically to
+	// avoid most redundant work.
+	//
+	// This is a hacky workaround: in the future we should avoid recomputing
+	// anything when codeActions provide a diagnostic: simply read the published
+	// diagnostic, if it exists.
 	s.mu.Lock()
-	meta := s.meta
+	existing := s.orphanedFileDiagnostics
 	s.mu.Unlock()
+	if existing != nil {
+		return existing, nil
+	}
+
+	if err := s.awaitLoaded(ctx); err != nil {
+		return nil, err
+	}
 
 	var files []*Overlay
 
@@ -1699,20 +1714,30 @@
 		if s.IsBuiltin(uri) || s.view.FileKind(o) != source.Go {
 			continue
 		}
-		for _, id := range meta.ids[o.URI()] {
-			if !source.IsCommandLineArguments(id) || meta.metadata[id].Standalone {
+		md, err := s.MetadataForFile(ctx, uri)
+		if err != nil {
+			return nil, err
+		}
+		for _, m := range md {
+			if !source.IsCommandLineArguments(m.ID) || m.Standalone {
 				continue searchOverlays
 			}
 		}
 		files = append(files, o)
 	}
 	if len(files) == 0 {
-		return nil
+		return nil, nil
 	}
 
-	loadedModFiles := make(map[span.URI]struct{})
-	ignoredFiles := make(map[span.URI]bool)
-	for _, meta := range meta.metadata {
+	loadedModFiles := make(map[span.URI]struct{}) // all mod files, including dependencies
+	ignoredFiles := make(map[span.URI]bool)       // files reported in packages.Package.IgnoredFiles
+
+	meta, err := s.AllMetadata(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, meta := range meta {
 		if meta.Module != nil && meta.Module.GoMod != "" {
 			gomod := span.URIFromPath(meta.Module.GoMod)
 			loadedModFiles[gomod] = struct{}{}
@@ -1740,15 +1765,25 @@
 			continue
 		}
 
+		var (
+			msg            string                // if non-empty, report a diagnostic with this message
+			suggestedFixes []source.SuggestedFix // associated fixes, if any
+		)
+
 		// If we have a relevant go.mod file, check whether the file is orphaned
 		// due to its go.mod file being inactive. We could also offer a
 		// prescriptive diagnostic in the case that there is no go.mod file, but it
 		// is harder to be precise in that case, and less important.
-		var msg string
 		if goMod, err := nearestModFile(ctx, fh.URI(), s); err == nil && goMod != "" {
 			if _, ok := loadedModFiles[goMod]; !ok {
 				modDir := filepath.Dir(goMod.Filename())
-				if rel, err := filepath.Rel(s.view.folder.Filename(), modDir); err == nil {
+				viewDir := s.view.folder.Filename()
+
+				// When the module is underneath the view dir, we offer
+				// "use all modules" quick-fixes.
+				inDir := source.InDir(viewDir, modDir)
+
+				if rel, err := filepath.Rel(viewDir, modDir); err == nil {
 					modDir = rel
 				}
 
@@ -1756,8 +1791,59 @@
 				if s.view.goversion >= 18 {
 					if s.view.gowork != "" {
 						fix = fmt.Sprintf("To fix this problem, you can add this module to your go.work file (%s)", s.view.gowork)
+						if cmd, err := command.NewRunGoWorkCommandCommand("Run `go work use`", command.RunGoWorkArgs{
+							ViewID: s.view.ID(),
+							Args:   []string{"use", modDir},
+						}); err == nil {
+							suggestedFixes = append(suggestedFixes, source.SuggestedFix{
+								Title:      "Use this module in your go.work file",
+								Command:    &cmd,
+								ActionKind: protocol.QuickFix,
+							})
+						}
+
+						if inDir {
+							if cmd, err := command.NewRunGoWorkCommandCommand("Run `go work use -r`", command.RunGoWorkArgs{
+								ViewID: s.view.ID(),
+								Args:   []string{"use", "-r", "."},
+							}); err == nil {
+								suggestedFixes = append(suggestedFixes, source.SuggestedFix{
+									Title:      "Use all modules in your workspace",
+									Command:    &cmd,
+									ActionKind: protocol.QuickFix,
+								})
+							}
+						}
 					} else {
 						fix = "To fix this problem, you can add a go.work file that uses this directory."
+
+						if cmd, err := command.NewRunGoWorkCommandCommand("Run `go work init && go work use`", command.RunGoWorkArgs{
+							ViewID:    s.view.ID(),
+							InitFirst: true,
+							Args:      []string{"use", modDir},
+						}); err == nil {
+							suggestedFixes = []source.SuggestedFix{
+								{
+									Title:      "Add a go.work file using this module",
+									Command:    &cmd,
+									ActionKind: protocol.QuickFix,
+								},
+							}
+						}
+
+						if inDir {
+							if cmd, err := command.NewRunGoWorkCommandCommand("Run `go work init && go work use -r`", command.RunGoWorkArgs{
+								ViewID:    s.view.ID(),
+								InitFirst: true,
+								Args:      []string{"use", "-r", "."},
+							}); err == nil {
+								suggestedFixes = append(suggestedFixes, source.SuggestedFix{
+									Title:      "Add a go.work file using all modules in your workspace",
+									Command:    &cmd,
+									ActionKind: protocol.QuickFix,
+								})
+							}
+						}
 					}
 				} else {
 					fix = `To work with multiple modules simultaneously, please upgrade to Go 1.18 or
@@ -1794,16 +1880,22 @@
 		if msg != "" {
 			// Only report diagnostics if we detect an actual exclusion.
 			diagnostics[fh.URI()] = &source.Diagnostic{
-				URI:      fh.URI(),
-				Range:    rng,
-				Severity: protocol.SeverityWarning,
-				Source:   source.ListError,
-				Message:  msg,
+				URI:            fh.URI(),
+				Range:          rng,
+				Severity:       protocol.SeverityWarning,
+				Source:         source.ListError,
+				Message:        msg,
+				SuggestedFixes: suggestedFixes,
 			}
 		}
 	}
 
-	return diagnostics
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.orphanedFileDiagnostics == nil { // another thread may have won the race
+		s.orphanedFileDiagnostics = diagnostics
+	}
+	return s.orphanedFileDiagnostics, nil
 }
 
 // TODO(golang/go#53756): this function needs to consider more than just the
@@ -1848,7 +1940,7 @@
 	reinit := false
 	wsModFiles, wsModFilesErr := s.workspaceModFiles, s.workspaceModFilesErr
 
-	if workURI := s.view.effectiveGOWORK(); workURI != "" {
+	if workURI, _ := s.view.GOWORK(); workURI != "" {
 		if change, ok := changes[workURI]; ok {
 			wsModFiles, wsModFilesErr = computeWorkspaceModFiles(ctx, s.view.gomod, workURI, s.view.effectiveGO111MODULE(), &unappliedChanges{
 				originalSnapshot: s,
diff --git a/gopls/internal/lsp/cache/view.go b/gopls/internal/lsp/cache/view.go
index 884b0fc..db2c1dc 100644
--- a/gopls/internal/lsp/cache/view.go
+++ b/gopls/internal/lsp/cache/view.go
@@ -145,13 +145,16 @@
 	}
 }
 
-// effectiveGOWORK returns the effective GOWORK value for this workspace, if
+// GOWORK returns the effective GOWORK value for this workspace, if
 // any, in URI form.
-func (w workspaceInformation) effectiveGOWORK() span.URI {
+//
+// The second result reports whether the effective GOWORK value is "" because
+// GOWORK=off.
+func (w workspaceInformation) GOWORK() (span.URI, bool) {
 	if w.gowork == "off" || w.gowork == "" {
-		return ""
+		return "", w.gowork == "off"
 	}
-	return span.URIFromPath(w.gowork)
+	return span.URIFromPath(w.gowork), false
 }
 
 // GO111MODULE returns the value of GO111MODULE to use for running the go
@@ -540,7 +543,7 @@
 	//
 	// TODO(rfindley): Make sure the go.work files are always known
 	// to the view.
-	if c.URI == v.effectiveGOWORK() {
+	if gowork, _ := v.GOWORK(); gowork == c.URI {
 		return true
 	}
 
diff --git a/gopls/internal/lsp/code_action.go b/gopls/internal/lsp/code_action.go
index 5819565..8658ba55 100644
--- a/gopls/internal/lsp/code_action.go
+++ b/gopls/internal/lsp/code_action.go
@@ -176,6 +176,18 @@
 					},
 				})
 			}
+
+			diags, err := snapshot.OrphanedFileDiagnostics(ctx)
+			if err != nil {
+				return nil, err
+			}
+			if d, ok := diags[fh.URI()]; ok {
+				quickFixes, err := codeActionsMatchingDiagnostics(ctx, snapshot, diagnostics, []*source.Diagnostic{d})
+				if err != nil {
+					return nil, err
+				}
+				codeActions = append(codeActions, quickFixes...)
+			}
 		}
 		if ctx.Err() != nil {
 			return nil, ctx.Err()
diff --git a/gopls/internal/lsp/command.go b/gopls/internal/lsp/command.go
index 6fa8312..7236087 100644
--- a/gopls/internal/lsp/command.go
+++ b/gopls/internal/lsp/command.go
@@ -22,6 +22,7 @@
 
 	"golang.org/x/mod/modfile"
 	"golang.org/x/tools/go/ast/astutil"
+	"golang.org/x/tools/gopls/internal/bug"
 	"golang.org/x/tools/gopls/internal/govulncheck"
 	"golang.org/x/tools/gopls/internal/lsp/cache"
 	"golang.org/x/tools/gopls/internal/lsp/command"
@@ -69,6 +70,7 @@
 	async       bool                 // whether to run the command asynchronously. Async commands can only return errors.
 	requireSave bool                 // whether all files must be saved for the command to work
 	progress    string               // title to use for progress reporting. If empty, no progress will be reported.
+	forView     string               // view to resolve to a snapshot; incompatible with forURI
 	forURI      protocol.DocumentURI // URI to resolve to a snapshot. If unset, snapshot will be nil.
 }
 
@@ -103,6 +105,9 @@
 		}
 	}
 	var deps commandDeps
+	if cfg.forURI != "" && cfg.forView != "" {
+		return bug.Errorf("internal error: forURI=%q, forView=%q", cfg.forURI, cfg.forView)
+	}
 	if cfg.forURI != "" {
 		var ok bool
 		var release func()
@@ -114,6 +119,17 @@
 			}
 			return fmt.Errorf("invalid file URL: %v", cfg.forURI)
 		}
+	} else if cfg.forView != "" {
+		view, err := c.s.session.View(cfg.forView)
+		if err != nil {
+			return err
+		}
+		var release func()
+		deps.snapshot, release, err = view.Snapshot()
+		if err != nil {
+			return err
+		}
+		defer release()
 	}
 	ctx, cancel := context.WithCancel(xcontext.Detach(ctx))
 	if cfg.progress != "" {
@@ -576,40 +592,26 @@
 	}
 	modURI := snapshot.GoModForFile(uri)
 	sumURI := span.URIFromPath(strings.TrimSuffix(modURI.Filename(), ".mod") + ".sum")
-	modEdits, err := applyFileEdits(ctx, snapshot, modURI, newModBytes)
+	modEdits, err := collectFileEdits(ctx, snapshot, modURI, newModBytes)
 	if err != nil {
 		return err
 	}
-	sumEdits, err := applyFileEdits(ctx, snapshot, sumURI, newSumBytes)
+	sumEdits, err := collectFileEdits(ctx, snapshot, sumURI, newSumBytes)
 	if err != nil {
 		return err
 	}
-	changes := append(sumEdits, modEdits...)
-	if len(changes) == 0 {
-		return nil
-	}
-	documentChanges := []protocol.DocumentChanges{} // must be a slice
-	for _, change := range changes {
-		change := change
-		documentChanges = append(documentChanges, protocol.DocumentChanges{
-			TextDocumentEdit: &change,
-		})
-	}
-	response, err := s.client.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{
-		Edit: protocol.WorkspaceEdit{
-			DocumentChanges: documentChanges,
-		},
-	})
-	if err != nil {
-		return err
-	}
-	if !response.Applied {
-		return fmt.Errorf("edits not applied because of %s", response.FailureReason)
-	}
-	return nil
+	return applyFileEdits(ctx, s.client, append(sumEdits, modEdits...))
 }
 
-func applyFileEdits(ctx context.Context, snapshot source.Snapshot, uri span.URI, newContent []byte) ([]protocol.TextDocumentEdit, error) {
+// collectFileEdits collects any file edits required to transform the snapshot
+// file specified by uri to the provided new content.
+//
+// If the file is not open, collectFileEdits simply writes the new content to
+// disk.
+//
+// TODO(rfindley): fix this API asymmetry. It should be up to the caller to
+// write the file or apply the edits.
+func collectFileEdits(ctx context.Context, snapshot source.Snapshot, uri span.URI, newContent []byte) ([]protocol.TextDocumentEdit, error) {
 	fh, err := snapshot.ReadFile(ctx, uri)
 	if err != nil {
 		return nil, err
@@ -618,6 +620,7 @@
 	if err != nil && !os.IsNotExist(err) {
 		return nil, err
 	}
+
 	if bytes.Equal(oldContent, newContent) {
 		return nil, nil
 	}
@@ -647,6 +650,31 @@
 	}}, nil
 }
 
+func applyFileEdits(ctx context.Context, cli protocol.Client, edits []protocol.TextDocumentEdit) error {
+	if len(edits) == 0 {
+		return nil
+	}
+	documentChanges := []protocol.DocumentChanges{} // must be a slice
+	for _, change := range edits {
+		change := change
+		documentChanges = append(documentChanges, protocol.DocumentChanges{
+			TextDocumentEdit: &change,
+		})
+	}
+	response, err := cli.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{
+		Edit: protocol.WorkspaceEdit{
+			DocumentChanges: documentChanges,
+		},
+	})
+	if err != nil {
+		return err
+	}
+	if !response.Applied {
+		return fmt.Errorf("edits not applied because of %s", response.FailureReason)
+	}
+	return nil
+}
+
 func runGoGetModule(invoke func(...string) (*bytes.Buffer, error), addRequire bool, args []string) error {
 	if addRequire {
 		if err := addModuleRequire(invoke, args); err != nil {
@@ -1038,3 +1066,82 @@
 
 	return stats
 }
+
+// RunGoWorkCommand invokes `go work <args>` with the provided arguments.
+//
+// args.InitFirst controls whether to first run `go work init`. This allows a
+// single command to both create and recursively populate a go.work file -- as
+// of writing there is no `go work init -r`.
+//
+// Some thought went into implementing this command. Unlike the go.mod commands
+// above, this command simply invokes the go command and relies on the client
+// to notify gopls of file changes via didChangeWatchedFile notifications.
+// We could instead run these commands with GOWORK set to a temp file, but that
+// poses the following problems:
+//   - directory locations in the resulting temp go.work file will be computed
+//     relative to the directory containing that go.work. If the go.work is in a
+//     tempdir, the directories will need to be translated to/from that dir.
+//   - it would be simpler to use a temp go.work file in the workspace
+//     directory, or whichever directory contains the real go.work file, but
+//     that sets a bad precedent of writing to a user-owned directory. We
+//     shouldn't start doing that.
+//   - Sending workspace edits to create a go.work file would require using
+//     the CreateFile resource operation, which would need to be tested in every
+//     client as we haven't used it before. We don't have time for that right
+//     now.
+//
+// Therefore, we simply require that the current go.work file is saved (if it
+// exists), and delegate to the go command.
+func (c *commandHandler) RunGoWorkCommand(ctx context.Context, args command.RunGoWorkArgs) error {
+	return c.run(ctx, commandConfig{
+		progress: "Running go work command",
+		forView:  args.ViewID,
+	}, func(ctx context.Context, deps commandDeps) (runErr error) {
+		snapshot := deps.snapshot
+		view := snapshot.View().(*cache.View)
+		viewDir := view.Folder().Filename()
+
+		// If the user has explicitly set GOWORK=off, we should warn them
+		// explicitly and avoid potentially misleading errors below.
+		goworkURI, off := view.GOWORK()
+		if off {
+			return fmt.Errorf("cannot modify go.work files when GOWORK=off")
+		}
+		gowork := goworkURI.Filename()
+
+		if goworkURI != "" {
+			fh, err := snapshot.ReadFile(ctx, goworkURI)
+			if err != nil {
+				return fmt.Errorf("reading current go.work file: %v", err)
+			}
+			if !fh.Saved() {
+				return fmt.Errorf("must save workspace file %s before running go work commands", goworkURI)
+			}
+		} else {
+			if !args.InitFirst {
+				// If go.work does not exist, we should have detected that and asked
+				// for InitFirst.
+				return bug.Errorf("internal error: cannot run go work command: required go.work file not found")
+			}
+			gowork = filepath.Join(viewDir, "go.work")
+			if err := c.invokeGoWork(ctx, viewDir, gowork, []string{"init"}); err != nil {
+				return fmt.Errorf("running `go work init`: %v", err)
+			}
+		}
+
+		return c.invokeGoWork(ctx, viewDir, gowork, args.Args)
+	})
+}
+
+func (c *commandHandler) invokeGoWork(ctx context.Context, viewDir, gowork string, args []string) error {
+	inv := gocommand.Invocation{
+		Verb:       "work",
+		Args:       args,
+		WorkingDir: viewDir,
+		Env:        append(os.Environ(), fmt.Sprintf("GOWORK=%s", gowork)),
+	}
+	if _, err := c.s.session.GoCommandRunner().Run(ctx, inv); err != nil {
+		return fmt.Errorf("running go work command: %v", err)
+	}
+	return nil
+}
diff --git a/gopls/internal/lsp/command/command_gen.go b/gopls/internal/lsp/command/command_gen.go
index a6f9940..8003b17 100644
--- a/gopls/internal/lsp/command/command_gen.go
+++ b/gopls/internal/lsp/command/command_gen.go
@@ -34,6 +34,7 @@
 	RegenerateCgo         Command = "regenerate_cgo"
 	RemoveDependency      Command = "remove_dependency"
 	ResetGoModDiagnostics Command = "reset_go_mod_diagnostics"
+	RunGoWorkCommand      Command = "run_go_work_command"
 	RunGovulncheck        Command = "run_govulncheck"
 	RunTests              Command = "run_tests"
 	StartDebugging        Command = "start_debugging"
@@ -62,6 +63,7 @@
 	RegenerateCgo,
 	RemoveDependency,
 	ResetGoModDiagnostics,
+	RunGoWorkCommand,
 	RunGovulncheck,
 	RunTests,
 	StartDebugging,
@@ -162,6 +164,12 @@
 			return nil, err
 		}
 		return nil, s.ResetGoModDiagnostics(ctx, a0)
+	case "gopls.run_go_work_command":
+		var a0 RunGoWorkArgs
+		if err := UnmarshalArgs(params.Arguments, &a0); err != nil {
+			return nil, err
+		}
+		return nil, s.RunGoWorkCommand(ctx, a0)
 	case "gopls.run_govulncheck":
 		var a0 VulncheckArgs
 		if err := UnmarshalArgs(params.Arguments, &a0); err != nil {
@@ -404,6 +412,18 @@
 	}, nil
 }
 
+func NewRunGoWorkCommandCommand(title string, a0 RunGoWorkArgs) (protocol.Command, error) {
+	args, err := MarshalArgs(a0)
+	if err != nil {
+		return protocol.Command{}, err
+	}
+	return protocol.Command{
+		Title:     title,
+		Command:   "gopls.run_go_work_command",
+		Arguments: args,
+	}, nil
+}
+
 func NewRunGovulncheckCommand(title string, a0 VulncheckArgs) (protocol.Command, error) {
 	args, err := MarshalArgs(a0)
 	if err != nil {
diff --git a/gopls/internal/lsp/command/interface.go b/gopls/internal/lsp/command/interface.go
index 969ed8a..1342e84 100644
--- a/gopls/internal/lsp/command/interface.go
+++ b/gopls/internal/lsp/command/interface.go
@@ -170,6 +170,10 @@
 	// This command is intended for internal use only, by the gopls stats
 	// command.
 	WorkspaceStats(context.Context) (WorkspaceStatsResult, error)
+
+	// RunGoWorkCommand: run `go work [args...]`, and apply the resulting go.work
+	// edits to the current go.work file.
+	RunGoWorkCommand(context.Context, RunGoWorkArgs) error
 }
 
 type RunTestsArgs struct {
@@ -447,3 +451,9 @@
 	CompiledGoFiles int // total number of compiled Go files across all packages
 	Modules         int // total number of unique modules
 }
+
+type RunGoWorkArgs struct {
+	ViewID    string   // ID of the view to run the command from
+	InitFirst bool     // Whether to run `go work init` first
+	Args      []string // Args to pass to `go work`
+}
diff --git a/gopls/internal/lsp/diagnostics.go b/gopls/internal/lsp/diagnostics.go
index 26f2242..90c2232 100644
--- a/gopls/internal/lsp/diagnostics.go
+++ b/gopls/internal/lsp/diagnostics.go
@@ -391,8 +391,14 @@
 	// Orphaned files.
 	// Confirm that every opened file belongs to a package (if any exist in
 	// the workspace). Otherwise, add a diagnostic to the file.
-	for uri, diag := range snapshot.OrphanedFileDiagnostics(ctx) {
-		s.storeDiagnostics(snapshot, uri, orphanedSource, []*source.Diagnostic{diag}, true)
+	if diags, err := snapshot.OrphanedFileDiagnostics(ctx); err == nil {
+		for uri, diag := range diags {
+			s.storeDiagnostics(snapshot, uri, orphanedSource, []*source.Diagnostic{diag}, true)
+		}
+	} else {
+		if ctx.Err() == nil {
+			event.Error(ctx, "computing orphaned file diagnostics", err, source.SnapshotLabels(snapshot)...)
+		}
 	}
 }
 
diff --git a/gopls/internal/lsp/lsp_test.go b/gopls/internal/lsp/lsp_test.go
index bbe1f1c..ed3baa2 100644
--- a/gopls/internal/lsp/lsp_test.go
+++ b/gopls/internal/lsp/lsp_test.go
@@ -241,7 +241,7 @@
 
 func (r *runner) Diagnostics(t *testing.T, uri span.URI, want []*source.Diagnostic) {
 	// Get the diagnostics for this view if we have not done it before.
-	v := r.server.session.View(r.data.Config.Dir)
+	v := r.server.session.ViewByName(r.data.Config.Dir)
 	r.collectDiagnostics(v)
 	tests.CompareDiagnostics(t, uri, want, r.diagnostics[uri])
 }
diff --git a/gopls/internal/lsp/regtest/marker.go b/gopls/internal/lsp/regtest/marker.go
index ddd05af..29722c9 100644
--- a/gopls/internal/lsp/regtest/marker.go
+++ b/gopls/internal/lsp/regtest/marker.go
@@ -1484,7 +1484,7 @@
 	// Apply the fix it suggests.
 	changed, err := codeAction(mark.run.env, loc.URI, loc.Range, actionKind, nil)
 	if err != nil {
-		mark.errorf("suggestedfix failed: %v. (Use @suggestedfixerr for expected errors.)", err)
+		mark.errorf("codeAction failed: %v", err)
 		return
 	}
 
diff --git a/gopls/internal/lsp/source/api_json.go b/gopls/internal/lsp/source/api_json.go
index d6fddc9..281772b 100644
--- a/gopls/internal/lsp/source/api_json.go
+++ b/gopls/internal/lsp/source/api_json.go
@@ -769,6 +769,12 @@
 			ArgDoc:  "{\n\t\"URIArg\": {\n\t\t\"URI\": string,\n\t},\n\t// Optional: source of the diagnostics to reset.\n\t// If not set, all resettable go.mod diagnostics will be cleared.\n\t\"DiagnosticSource\": string,\n}",
 		},
 		{
+			Command: "gopls.run_go_work_command",
+			Title:   "run `go work [args...]`, and apply the resulting go.work",
+			Doc:     "edits to the current go.work file.",
+			ArgDoc:  "{\n\t\"ViewID\": string,\n\t\"InitFirst\": bool,\n\t\"Args\": []string,\n}",
+		},
+		{
 			Command:   "gopls.run_govulncheck",
 			Title:     "Run govulncheck.",
 			Doc:       "Run vulnerability check (`govulncheck`).",
diff --git a/gopls/internal/lsp/source/view.go b/gopls/internal/lsp/source/view.go
index 2a16ad6..e77f9d2 100644
--- a/gopls/internal/lsp/source/view.go
+++ b/gopls/internal/lsp/source/view.go
@@ -209,7 +209,9 @@
 
 	// OrphanedFileDiagnostics reports diagnostics for files that have no package
 	// associations or which only have only command-line-arguments packages.
-	OrphanedFileDiagnostics(ctx context.Context) map[span.URI]*Diagnostic
+	//
+	// The caller must not mutate the result.
+	OrphanedFileDiagnostics(ctx context.Context) (map[span.URI]*Diagnostic, error)
 
 	// -- package type-checking --
 
diff --git a/gopls/internal/lsp/workspace.go b/gopls/internal/lsp/workspace.go
index 53cdcac..818135e 100644
--- a/gopls/internal/lsp/workspace.go
+++ b/gopls/internal/lsp/workspace.go
@@ -17,7 +17,7 @@
 func (s *Server) didChangeWorkspaceFolders(ctx context.Context, params *protocol.DidChangeWorkspaceFoldersParams) error {
 	event := params.Event
 	for _, folder := range event.Removed {
-		view := s.session.View(folder.Name)
+		view := s.session.ViewByName(folder.Name)
 		if view != nil {
 			s.session.RemoveView(view)
 		} else {
diff --git a/gopls/internal/regtest/marker/testdata/quickfix/addgowork.txt b/gopls/internal/regtest/marker/testdata/diagnostics/addgowork.txt
similarity index 84%
rename from gopls/internal/regtest/marker/testdata/quickfix/addgowork.txt
rename to gopls/internal/regtest/marker/testdata/diagnostics/addgowork.txt
index dbd1a95..2cb7d2b 100644
--- a/gopls/internal/regtest/marker/testdata/quickfix/addgowork.txt
+++ b/gopls/internal/regtest/marker/testdata/diagnostics/addgowork.txt
@@ -1,6 +1,7 @@
-This test demonstrates the quick-fix for adding a go.work file.
+This test demonstrates diagnostics for adding a go.work file.
 
-TODO(rfindley): actually add quick-fixes here.
+Quick-fixes change files on disk, so are tested by regtests.
+
 TODO(rfindley): improve the "cannot find package" import errors.
 
 -- flags --
diff --git a/gopls/internal/regtest/marker/testdata/quickfix/usemodule.txt b/gopls/internal/regtest/marker/testdata/diagnostics/usemodule.txt
similarity index 79%
rename from gopls/internal/regtest/marker/testdata/quickfix/usemodule.txt
rename to gopls/internal/regtest/marker/testdata/diagnostics/usemodule.txt
index 62f4efc..35d2e43 100644
--- a/gopls/internal/regtest/marker/testdata/quickfix/usemodule.txt
+++ b/gopls/internal/regtest/marker/testdata/diagnostics/usemodule.txt
@@ -1,6 +1,7 @@
-This test demonstrates the quick-fix for using a module directory.
+This test demonstrates diagnostics for a module that is missing from the
+go.work file.
 
-TODO(rfindley): actually add quick-fixes here.
+Quick-fixes change files on disk, so are tested by regtests.
 
 -- flags --
 -min_go=go1.18
@@ -11,6 +12,7 @@
 use (
 	./a
 )
+
 -- a/go.mod --
 module mod.com/a
 
diff --git a/gopls/internal/regtest/workspace/quickfix_test.go b/gopls/internal/regtest/workspace/quickfix_test.go
new file mode 100644
index 0000000..5cb08f0
--- /dev/null
+++ b/gopls/internal/regtest/workspace/quickfix_test.go
@@ -0,0 +1,344 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package workspace
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"golang.org/x/tools/gopls/internal/lsp/protocol"
+	"golang.org/x/tools/gopls/internal/lsp/tests/compare"
+	"golang.org/x/tools/internal/testenv"
+
+	. "golang.org/x/tools/gopls/internal/lsp/regtest"
+)
+
+func TestQuickFix_UseModule(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18) // needs go.work
+
+	const files = `
+-- go.work --
+go 1.20
+
+use (
+	./a
+)
+-- a/go.mod --
+module mod.com/a
+
+go 1.18
+
+-- a/main.go --
+package main
+
+import "mod.com/a/lib"
+
+func main() {
+	_ = lib.C
+}
+
+-- a/lib/lib.go --
+package lib
+
+const C = "b"
+-- b/go.mod --
+module mod.com/b
+
+go 1.18
+
+-- b/main.go --
+package main
+
+import "mod.com/b/lib"
+
+func main() {
+	_ = lib.C
+}
+
+-- b/lib/lib.go --
+package lib
+
+const C = "b"
+`
+
+	for _, title := range []string{
+		"Use this module",
+		"Use all modules",
+	} {
+		t.Run(title, func(t *testing.T) {
+			Run(t, files, func(t *testing.T, env *Env) {
+				env.OpenFile("b/main.go")
+				var d protocol.PublishDiagnosticsParams
+				env.AfterChange(ReadDiagnostics("b/main.go", &d))
+				fixes := env.GetQuickFixes("b/main.go", d.Diagnostics)
+				var toApply []protocol.CodeAction
+				for _, fix := range fixes {
+					if strings.Contains(fix.Title, title) {
+						toApply = append(toApply, fix)
+					}
+				}
+				if len(toApply) != 1 {
+					t.Fatalf("codeAction: got %d quick fixes matching %q, want 1; got: %v", len(toApply), title, toApply)
+				}
+				env.ApplyCodeAction(toApply[0])
+				env.AfterChange(NoDiagnostics())
+				want := `go 1.20
+
+use (
+	./a
+	./b
+)
+`
+				got := env.ReadWorkspaceFile("go.work")
+				if diff := compare.Text(want, got); diff != "" {
+					t.Errorf("unexpeced go.work content:\n%s", diff)
+				}
+			})
+		})
+	}
+}
+
+func TestQuickFix_AddGoWork(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18) // needs go.work
+
+	v := goVersion(t)
+	const files = `
+-- a/go.mod --
+module mod.com/a
+
+go 1.18
+
+-- a/main.go --
+package main
+
+import "mod.com/a/lib"
+
+func main() {
+	_ = lib.C
+}
+
+-- a/lib/lib.go --
+package lib
+
+const C = "b"
+-- b/go.mod --
+module mod.com/b
+
+go 1.18
+
+-- b/main.go --
+package main
+
+import "mod.com/b/lib"
+
+func main() {
+	_ = lib.C
+}
+
+-- b/lib/lib.go --
+package lib
+
+const C = "b"
+`
+
+	tests := []struct {
+		name  string
+		file  string
+		title string
+		want  string
+	}{
+		{
+			"use b",
+			"b/main.go",
+			"Add a go.work file using this module",
+			fmt.Sprintf(`go 1.%d
+
+use ./b
+`, v),
+		},
+		{
+			"use a",
+			"a/main.go",
+			"Add a go.work file using this module",
+			fmt.Sprintf(`go 1.%d
+
+use ./a
+`, v),
+		},
+		{
+			"use all",
+			"a/main.go",
+			"Add a go.work file using all modules",
+			fmt.Sprintf(`go 1.%d
+
+use (
+	./a
+	./b
+)
+`, v),
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			Run(t, files, func(t *testing.T, env *Env) {
+				env.OpenFile(test.file)
+				var d protocol.PublishDiagnosticsParams
+				env.AfterChange(ReadDiagnostics(test.file, &d))
+				fixes := env.GetQuickFixes(test.file, d.Diagnostics)
+				var toApply []protocol.CodeAction
+				for _, fix := range fixes {
+					if strings.Contains(fix.Title, test.title) {
+						toApply = append(toApply, fix)
+					}
+				}
+				if len(toApply) != 1 {
+					t.Fatalf("codeAction: got %d quick fixes matching %q, want 1; got: %v", len(toApply), test.title, toApply)
+				}
+				env.ApplyCodeAction(toApply[0])
+				env.AfterChange(
+					NoDiagnostics(ForFile(test.file)),
+				)
+
+				got := env.ReadWorkspaceFile("go.work")
+				if diff := compare.Text(test.want, got); diff != "" {
+					t.Errorf("unexpected go.work content:\n%s", diff)
+				}
+			})
+		})
+	}
+}
+
+func TestQuickFix_UnsavedGoWork(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18) // needs go.work
+
+	const files = `
+-- go.work --
+go 1.21
+
+use (
+	./a
+)
+-- a/go.mod --
+module mod.com/a
+
+go 1.18
+
+-- a/main.go --
+package main
+
+func main() {}
+-- b/go.mod --
+module mod.com/b
+
+go 1.18
+
+-- b/main.go --
+package main
+
+func main() {}
+`
+
+	for _, title := range []string{
+		"Use this module",
+		"Use all modules",
+	} {
+		t.Run(title, func(t *testing.T) {
+			Run(t, files, func(t *testing.T, env *Env) {
+				env.OpenFile("go.work")
+				env.OpenFile("b/main.go")
+				env.RegexpReplace("go.work", "go 1.21", "go 1.21 // arbitrary comment")
+				var d protocol.PublishDiagnosticsParams
+				env.AfterChange(ReadDiagnostics("b/main.go", &d))
+				fixes := env.GetQuickFixes("b/main.go", d.Diagnostics)
+				var toApply []protocol.CodeAction
+				for _, fix := range fixes {
+					if strings.Contains(fix.Title, title) {
+						toApply = append(toApply, fix)
+					}
+				}
+				if len(toApply) != 1 {
+					t.Fatalf("codeAction: got %d quick fixes matching %q, want 1; got: %v", len(toApply), title, toApply)
+				}
+				fix := toApply[0]
+				err := env.Editor.ApplyCodeAction(env.Ctx, fix)
+				if err == nil {
+					t.Fatalf("codeAction(%q) succeeded unexpectedly", fix.Title)
+				}
+
+				if got := err.Error(); !strings.Contains(got, "must save") {
+					t.Errorf("codeAction(%q) returned error %q, want containing \"must save\"", fix.Title, err)
+				}
+			})
+		})
+	}
+}
+
+func TestQuickFix_GOWORKOff(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18) // needs go.work
+
+	const files = `
+-- go.work --
+go 1.21
+
+use (
+	./a
+)
+-- a/go.mod --
+module mod.com/a
+
+go 1.18
+
+-- a/main.go --
+package main
+
+func main() {}
+-- b/go.mod --
+module mod.com/b
+
+go 1.18
+
+-- b/main.go --
+package main
+
+func main() {}
+`
+
+	for _, title := range []string{
+		"Use this module",
+		"Use all modules",
+	} {
+		t.Run(title, func(t *testing.T) {
+			WithOptions(
+				EnvVars{"GOWORK": "off"},
+			).Run(t, files, func(t *testing.T, env *Env) {
+				env.OpenFile("go.work")
+				env.OpenFile("b/main.go")
+				var d protocol.PublishDiagnosticsParams
+				env.AfterChange(ReadDiagnostics("b/main.go", &d))
+				fixes := env.GetQuickFixes("b/main.go", d.Diagnostics)
+				var toApply []protocol.CodeAction
+				for _, fix := range fixes {
+					if strings.Contains(fix.Title, title) {
+						toApply = append(toApply, fix)
+					}
+				}
+				if len(toApply) != 1 {
+					t.Fatalf("codeAction: got %d quick fixes matching %q, want 1; got: %v", len(toApply), title, toApply)
+				}
+				fix := toApply[0]
+				err := env.Editor.ApplyCodeAction(env.Ctx, fix)
+				if err == nil {
+					t.Fatalf("codeAction(%q) succeeded unexpectedly", fix.Title)
+				}
+
+				if got := err.Error(); !strings.Contains(got, "GOWORK=off") {
+					t.Errorf("codeAction(%q) returned error %q, want containing \"GOWORK=off\"", fix.Title, err)
+				}
+			})
+		})
+	}
+}