Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement experimental support for neovim #1355

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions cmd/agent/container/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/loft-sh/devpod/pkg/ide/jetbrains"
"github.com/loft-sh/devpod/pkg/ide/jupyter"
"github.com/loft-sh/devpod/pkg/ide/marimo"
"github.com/loft-sh/devpod/pkg/ide/neovim"
"github.com/loft-sh/devpod/pkg/ide/openvscode"
"github.com/loft-sh/devpod/pkg/ide/vscode"
provider2 "github.com/loft-sh/devpod/pkg/provider"
Expand Down Expand Up @@ -439,6 +440,8 @@ func (cmd *SetupContainerCmd) installIDE(setupInfo *config.Result, ide *provider
return jupyter.NewJupyterNotebookServer(setupInfo.SubstitutionContext.ContainerWorkspaceFolder, config.GetRemoteUser(setupInfo), ide.Options, log).Install()
case string(config2.IDEMarimo):
return marimo.NewServer(setupInfo.SubstitutionContext.ContainerWorkspaceFolder, config.GetRemoteUser(setupInfo), ide.Options, log).Install()
case string(config2.IDENeoVim):
return neovim.NewServer(config.GetRemoteUser(setupInfo), ide.Options, log).Install(setupInfo.SubstitutionContext.ContainerWorkspaceFolder)
}

return nil
Expand Down
58 changes: 58 additions & 0 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/loft-sh/devpod/pkg/ide/jetbrains"
"github.com/loft-sh/devpod/pkg/ide/jupyter"
"github.com/loft-sh/devpod/pkg/ide/marimo"
"github.com/loft-sh/devpod/pkg/ide/neovim"
"github.com/loft-sh/devpod/pkg/ide/openvscode"
"github.com/loft-sh/devpod/pkg/ide/vscode"
"github.com/loft-sh/devpod/pkg/loft"
Expand Down Expand Up @@ -343,6 +344,18 @@ func (cmd *UpCmd) Run(
return jetbrains.NewWebStormServer(config2.GetRemoteUser(result), ideConfig.Options, log).OpenGateway(result.SubstitutionContext.ContainerWorkspaceFolder, client.Workspace())
case string(config.IDEFleet):
return startFleet(ctx, client, log)
case string(config.IDENeoVim):
return startNeoVim(
cmd.GPGAgentForwarding,
ctx,
devPodConfig,
client,
user,
ideConfig.Options,
cmd.GitUsername,
cmd.GitToken,
log,
)
case string(config.IDEJupyterNotebook):
return startJupyterNotebookInBrowser(
cmd.GPGAgentForwarding,
Expand Down Expand Up @@ -653,6 +666,51 @@ func startMarimoInBrowser(
)
}

func startNeoVim(
forwardGpg bool,
ctx context.Context,
devPodConfig *config.Config,
client client2.BaseWorkspaceClient,
user string,
ideOptions map[string]config.OptionValue,
gitUsername, gitToken string,
logger log.Logger,
) error {
if forwardGpg {
err := performGpgForwarding(client, logger)
if err != nil {
return err
}
}
// determine port
address, port, err := parseAddressAndPort(
neovim.Options.GetValue(ideOptions, neovim.BindAddressOption),
marimo.DefaultServerPort,
)
if err != nil {
return err
}
// start tunnel
targetURL := fmt.Sprintf("http://127.0.0.1:%d", port)
logger.Info("======================================================================")
logger.Info("NeoVim has started on the remote, connect to it in a terminal using:")
logger.Infof("nvim --server %s --remote-ui", neovim.Options.GetValue(ideOptions, neovim.BindAddressOption))
logger.Info("======================================================================")
extraPorts := []string{fmt.Sprintf("%s:%d", address, neovim.DefaultServerPort)}
return startBrowserTunnel(
ctx,
devPodConfig,
client,
user,
targetURL,
false,
extraPorts,
gitUsername,
gitToken,
logger,
)
}

func startJupyterNotebookInBrowser(
forwardGpg bool,
ctx context.Context,
Expand Down
1 change: 1 addition & 0 deletions pkg/config/ide.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ const (
IDECursor IDE = "cursor"
IDEPositron IDE = "positron"
IDEMarimo IDE = "marimo"
IDENeoVim IDE = "neovim"
)
7 changes: 7 additions & 0 deletions pkg/ide/ideparse/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ var AllowedIDEs = []AllowedIDE{
Icon: "https://devpod.sh/assets/marimo.svg",
Experimental: true,
},
{
Name: config.IDENeoVim,
DisplayName: "NeoVim",
Options: vscode.Options,
Icon: "https://devpod.sh/assets/nvim.svg",
Experimental: true,
},
}

func RefreshIDEOptions(devPodConfig *config.Config, workspace *provider.Workspace, ide string, options []string) (*provider.Workspace, error) {
Expand Down
47 changes: 3 additions & 44 deletions pkg/ide/jetbrains/generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,14 @@ import (
"path"
"path/filepath"
"runtime"
"time"

"github.com/loft-sh/devpod/pkg/command"
"github.com/loft-sh/devpod/pkg/config"
copy2 "github.com/loft-sh/devpod/pkg/copy"
"github.com/loft-sh/devpod/pkg/extract"
devpodhttp "github.com/loft-sh/devpod/pkg/http"
"github.com/loft-sh/devpod/pkg/ide"
"github.com/loft-sh/devpod/pkg/util"
"github.com/loft-sh/log"
"github.com/mitchellh/go-homedir"
"github.com/pkg/errors"
"github.com/skratchdot/open-golang/open"
)
Expand Down Expand Up @@ -96,7 +94,7 @@ func (o *GenericJetBrainsServer) getDownloadFolder() string {

func (o *GenericJetBrainsServer) Install() error {
o.log.Debugf("Setup %s...", o.options.DisplayName)
baseFolder, err := getBaseFolder(o.userName)
baseFolder, err := util.GetBaseFolder(o.userName)
if err != nil {
return err
}
Expand Down Expand Up @@ -128,21 +126,6 @@ func (o *GenericJetBrainsServer) Install() error {
return nil
}

func getBaseFolder(userName string) (string, error) {
var err error
homeFolder := ""
if userName != "" {
homeFolder, err = command.GetHome(userName)
} else {
homeFolder, err = homedir.Dir()
}
if err != nil {
return "", err
}

return homeFolder, nil
}

func (o *GenericJetBrainsServer) getDirectory(baseFolder string) string {
return path.Join(baseFolder, ".cache", "JetBrains", "RemoteDev", "dist", o.options.ID)
}
Expand Down Expand Up @@ -194,34 +177,10 @@ func (o *GenericJetBrainsServer) download(targetFolder string, log log.Logger) (
}
defer file.Close()

_, err = io.Copy(file, &progressReader{
reader: resp.Body,
totalSize: resp.ContentLength,
log: log,
})
_, err = io.Copy(file, util.NewProgressReader(resp, log))
if err != nil {
return "", errors.Wrap(err, "download file")
}

return targetPath, nil
}

type progressReader struct {
reader io.Reader

lastMessage time.Time
bytesRead int64
totalSize int64
log log.Logger
}

func (p *progressReader) Read(b []byte) (n int, err error) {
n, err = p.reader.Read(b)
p.bytesRead += int64(n)
if time.Since(p.lastMessage) > time.Second*1 {
p.log.Infof("Downloaded %.2f / %.2f MB", float64(p.bytesRead)/1024/1024, float64(p.totalSize/1024/1024))
p.lastMessage = time.Now()
}

return n, err
}
158 changes: 158 additions & 0 deletions pkg/ide/neovim/neovim.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package neovim

import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"

"github.com/loft-sh/devpod/pkg/config"
copy2 "github.com/loft-sh/devpod/pkg/copy"
"github.com/loft-sh/devpod/pkg/extract"
devpodhttp "github.com/loft-sh/devpod/pkg/http"
"github.com/loft-sh/devpod/pkg/ide"
"github.com/loft-sh/devpod/pkg/single"
"github.com/loft-sh/devpod/pkg/util"
"github.com/loft-sh/log"
"github.com/pkg/errors"
)

const DefaultServerPort = 10720
const (
DownLoadURL = "DOWNLOAD_URL"
BindAddressOption = "BIND_ADDRESS"
)

var Options = ide.Options{
DownLoadURL: {
Name: DownLoadURL,
Description: "URL to use to download nvim",
Default: "https://github.com/neovim/neovim/releases/latest/download/nvim-linux64.tar.gz",
},
BindAddressOption: {
Name: BindAddressOption,
Description: "The address to bind the server to locally. E.g. 0.0.0.0:12345",
Default: "127.0.0.1:10720",
},
}

// NewServer creates a new neovim server
func NewServer(userName string, values map[string]config.OptionValue, log log.Logger) *Server {
return &Server{
userName: userName,
values: values,
log: log,
}
}

// Server provides the remote the ability to download, install and run the neovim server in headless mode
type Server struct {
userName string
values map[string]config.OptionValue
log log.Logger
}

func (o *Server) Install(workspaceFolder string) error {
o.log.Debugf("Setup neovim...")
// Define out target install location and ensure it exists
baseFolder, err := util.GetBaseFolder(o.userName)
if err != nil {
return err
}
targetLocation := path.Join(baseFolder, ".cache", "neovim")

_, err = os.Stat(targetLocation)
if err != nil {
o.log.Debugf("Installing neovim")
// Download and extract neovim
o.log.Debugf("Download neovim archive")
archivePath, err := o.download("/var/devpod/neovim", o.log)
if err != nil {
return err
}
o.log.Infof("Extract neovim...")
err = o.extractArchive(archivePath, targetLocation)
if err != nil {
return err
}
// Ensure the remote user owns the neovim install
err = copy2.ChownR(path.Join(baseFolder, ".cache"), o.userName)
if err != nil {
return errors.Wrap(err, "chown")
}
}

return o.start(targetLocation, workspaceFolder)
}

func (o *Server) extractArchive(fromPath string, toPath string) error {
file, err := os.Open(fromPath)
if err != nil {
return err
}
defer file.Close()

return extract.Extract(file, toPath, extract.StripLevels(1))
}

func (o *Server) download(targetFolder string, log log.Logger) (string, error) {
// Ensure the target folder exists
err := os.MkdirAll(targetFolder, os.ModePerm)
if err != nil {
return "", err
}
downloadURL := Options.GetValue(o.values, DownLoadURL)
targetPath := path.Join(filepath.ToSlash(targetFolder), "nvim-linux64.tar.gz")

// initiate download
log.Infof("Download neovim from %s", downloadURL)
defer log.Debugf("Successfully downloaded neovim")
resp, err := devpodhttp.GetHTTPClient().Get(downloadURL)
if err != nil {
return "", errors.Wrap(err, "download binary")
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", errors.Wrapf(err, "download binary returned status code %d", resp.StatusCode)
}
stat, err := os.Stat(targetPath)
if err == nil && stat.Size() == resp.ContentLength {
return targetPath, nil
}
// Download the response as a file
file, err := os.Create(targetPath)
if err != nil {
return "", err
}
defer file.Close()

_, err = io.Copy(file, util.NewProgressReader(resp, log))
if err != nil {
return "", errors.Wrap(err, "download file")
}
return targetPath, nil
}

// start runs the neovim server in headless mode using a known PID file to expose at most one instance
func (o *Server) start(targetLocation, workspaceFolder string) error {
return single.Single("nvim.pid", func() (*exec.Cmd, error) {
o.log.Debug("Starting nvim in background...")
// Generate server start command using remote user
addr := Options.GetValue(o.values, BindAddressOption)
runCommand := fmt.Sprintf("%s/bin/nvim --listen %s --headless %s", targetLocation, addr, workspaceFolder)
args := []string{}
if o.userName != "" {
args = append(args, "su", o.userName, "-l", "-c", runCommand)
} else {
args = append(args, "sh", "-l", "-c", runCommand)
}
// Execute the command in the workspace folder
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = workspaceFolder
return cmd, nil
})
}
Loading
Loading