diff --git a/.deadcode-out b/.deadcode-out index 1b33525a6d..ed1f49da9b 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -292,9 +292,6 @@ package "code.gitea.io/gitea/modules/translation" func (MockLocale).TrN func (MockLocale).PrettyNumber -package "code.gitea.io/gitea/modules/util" - func UnsafeStringToBytes - package "code.gitea.io/gitea/modules/util/filebuffer" func CreateFromReader diff --git a/modules/setting/config.go b/modules/setting/config.go index db189f44ac..03558574c2 100644 --- a/modules/setting/config.go +++ b/modules/setting/config.go @@ -15,8 +15,45 @@ type PictureStruct struct { EnableFederatedAvatar *config.Value[bool] } +type OpenWithEditorApp struct { + DisplayName string + OpenURL string +} + +type OpenWithEditorAppsType []OpenWithEditorApp + +func (t OpenWithEditorAppsType) ToTextareaString() string { + ret := "" + for _, app := range t { + ret += app.DisplayName + " = " + app.OpenURL + "\n" + } + return ret +} + +func DefaultOpenWithEditorApps() OpenWithEditorAppsType { + return OpenWithEditorAppsType{ + { + DisplayName: "VS Code", + OpenURL: "vscode://vscode.git/clone?url={url}", + }, + { + DisplayName: "VSCodium", + OpenURL: "vscodium://vscode.git/clone?url={url}", + }, + { + DisplayName: "Intellij IDEA", + OpenURL: "jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo={url}", + }, + } +} + +type RepositoryStruct struct { + OpenWithEditorApps *config.Value[OpenWithEditorAppsType] +} + type ConfigStruct struct { - Picture *PictureStruct + Picture *PictureStruct + Repository *RepositoryStruct } var ( @@ -28,8 +65,11 @@ func initDefaultConfig() { config.SetCfgSecKeyGetter(&cfgSecKeyGetter{}) defaultConfig = &ConfigStruct{ Picture: &PictureStruct{ - DisableGravatar: config.Bool(false, config.CfgSecKey{Sec: "picture", Key: "DISABLE_GRAVATAR"}, "picture.disable_gravatar"), - EnableFederatedAvatar: config.Bool(false, config.CfgSecKey{Sec: "picture", Key: "ENABLE_FEDERATED_AVATAR"}, "picture.enable_federated_avatar"), + DisableGravatar: config.ValueJSON[bool]("picture.disable_gravatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "DISABLE_GRAVATAR"}), + EnableFederatedAvatar: config.ValueJSON[bool]("picture.enable_federated_avatar").WithFileConfig(config.CfgSecKey{Sec: "picture", Key: "ENABLE_FEDERATED_AVATAR"}), + }, + Repository: &RepositoryStruct{ + OpenWithEditorApps: config.ValueJSON[OpenWithEditorAppsType]("repository.open-with.editor-apps"), }, } } @@ -42,6 +82,9 @@ func Config() *ConfigStruct { type cfgSecKeyGetter struct{} func (c cfgSecKeyGetter) GetValue(sec, key string) (v string, has bool) { + if key == "" { + return "", false + } cfgSec, err := CfgProvider.GetSection(sec) if err != nil { log.Error("Unable to get config section: %q", sec) diff --git a/modules/setting/config/value.go b/modules/setting/config/value.go index 817fcdb786..f0ec120544 100644 --- a/modules/setting/config/value.go +++ b/modules/setting/config/value.go @@ -5,8 +5,11 @@ package config import ( "context" - "strconv" "sync" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" ) type CfgSecKey struct { @@ -23,14 +26,14 @@ type Value[T any] struct { revision int } -func (value *Value[T]) parse(s string) (v T) { - switch any(v).(type) { - case bool: - b, _ := strconv.ParseBool(s) - return any(b).(T) - default: - panic("unsupported config type, please complete the code") +func (value *Value[T]) parse(key, valStr string) (v T) { + v = value.def + if valStr != "" { + if err := json.Unmarshal(util.UnsafeStringToBytes(valStr), &v); err != nil { + log.Error("Unable to unmarshal json config for key %q, err: %v", key, err) + } } + return v } func (value *Value[T]) Value(ctx context.Context) (v T) { @@ -62,7 +65,7 @@ func (value *Value[T]) Value(ctx context.Context) (v T) { if valStr == nil { v = value.def } else { - v = value.parse(*valStr) + v = value.parse(value.dynKey, *valStr) } value.mu.Lock() @@ -76,6 +79,16 @@ func (value *Value[T]) DynKey() string { return value.dynKey } -func Bool(def bool, cfgSecKey CfgSecKey, dynKey string) *Value[bool] { - return &Value[bool]{def: def, cfgSecKey: cfgSecKey, dynKey: dynKey} +func (value *Value[T]) WithDefault(def T) *Value[T] { + value.def = def + return value +} + +func (value *Value[T]) WithFileConfig(cfgSecKey CfgSecKey) *Value[T] { + value.cfgSecKey = cfgSecKey + return value +} + +func ValueJSON[T any](dynKey string) *Value[T] { + return &Value[T]{dynKey: dynKey} } diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 65f8d11b8d..50f0fd704c 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -7,7 +7,6 @@ import ( "os/exec" "path" "path/filepath" - "slices" "strings" "code.gitea.io/gitea/modules/log" @@ -20,8 +19,6 @@ const ( RepoCreatingPublic = "public" ) -var RecognisedRepositoryDownloadOrCloneMethods = []string{"download-zip", "download-targz", "download-bundle", "vscode-clone", "vscodium-clone", "cite"} - // MaxUserCardsPerPage sets maximum amount of watchers and stargazers shown per page // those pages use 2 or 3 column layout, so the value should be divisible by 2 and 3 var MaxUserCardsPerPage = 36 @@ -50,7 +47,6 @@ var ( DisabledRepoUnits []string DefaultRepoUnits []string DefaultForkRepoUnits []string - DownloadOrCloneMethods []string PrefixArchiveFiles bool DisableMigrations bool DisableStars bool @@ -173,7 +169,6 @@ var ( DisabledRepoUnits: []string{}, DefaultRepoUnits: []string{}, DefaultForkRepoUnits: []string{}, - DownloadOrCloneMethods: []string{"download-zip", "download-targz", "download-bundle", "vscode-clone"}, PrefixArchiveFiles: true, DisableMigrations: false, DisableStars: false, @@ -377,12 +372,5 @@ func loadRepositoryFrom(rootCfg ConfigProvider) { if err := loadRepoArchiveFrom(rootCfg); err != nil { log.Fatal("loadRepoArchiveFrom: %v", err) } - - for _, method := range Repository.DownloadOrCloneMethods { - if !slices.Contains(RecognisedRepositoryDownloadOrCloneMethods, method) { - log.Error("Unrecognised repository download or clone method: %s", method) - } - } - Repository.EnableFlags = sec.Key("ENABLE_FLAGS").MustBool() } diff --git a/modules/web/middleware/data.go b/modules/web/middleware/data.go index c1d1af8528..08d83f94be 100644 --- a/modules/web/middleware/data.go +++ b/modules/web/middleware/data.go @@ -53,7 +53,6 @@ func CommonTemplateContextData() ContextData { "ShowMilestonesDashboardPage": setting.Service.ShowMilestonesDashboardPage, "ShowFooterVersion": setting.Other.ShowFooterVersion, "DisableDownloadSourceArchives": setting.Repository.DisableDownloadSourceArchives, - "DownloadOrCloneMethods": setting.Repository.DownloadOrCloneMethods, "EnableSwagger": setting.API.EnableSwagger, "EnableOpenIDSignIn": setting.Service.EnableOpenIDSignIn, diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 5233c737ba..50871be86d 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1015,8 +1015,7 @@ fork_branch = Branch to be cloned to the fork all_branches = All branches fork_no_valid_owners = This repository can not be forked because there are no valid owners. use_template = Use this template -clone_in_vsc = Clone in VS Code -clone_in_vscodium = Clone in VSCodium +open_with_editor = Open with %s download_zip = Download ZIP download_tar = Download TAR.GZ download_bundle = Download BUNDLE @@ -2833,6 +2832,8 @@ authentication = Authentication sources emails = User emails config = Configuration notices = System notices +config_summary = Summary +config_settings = Settings monitor = Monitoring first_page = First last_page = Last @@ -3271,6 +3272,7 @@ config.picture_config = Picture and avatar configuration config.picture_service = Picture service config.disable_gravatar = Disable Gravatar config.enable_federated_avatar = Enable federated avatars +config.open_with_editor_app_help = The "Open with" editors for the clone menu. If left empty, the default will be used. Expand to see the default. config.git_config = Git configuration config.git_disable_diff_highlight = Disable diff syntax highlighting diff --git a/public/assets/img/svg/gitea-open-with-jetbrains.svg b/public/assets/img/svg/gitea-open-with-jetbrains.svg new file mode 100644 index 0000000000..2b1491b541 --- /dev/null +++ b/public/assets/img/svg/gitea-open-with-jetbrains.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-open-with-vscode.svg b/public/assets/img/svg/gitea-open-with-vscode.svg new file mode 100644 index 0000000000..151c45e210 --- /dev/null +++ b/public/assets/img/svg/gitea-open-with-vscode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-open-with-vscodium.svg b/public/assets/img/svg/gitea-open-with-vscodium.svg new file mode 100644 index 0000000000..9f70878ba6 --- /dev/null +++ b/public/assets/img/svg/gitea-open-with-vscodium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-vscode.svg b/public/assets/img/svg/gitea-vscode.svg deleted file mode 100644 index 453b9befcc..0000000000 --- a/public/assets/img/svg/gitea-vscode.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index d9b1973332..2f5f17e201 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -7,11 +7,11 @@ package admin import ( "net/http" "net/url" + "strconv" "strings" system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" @@ -24,7 +24,10 @@ import ( "gitea.com/go-chi/session" ) -const tplConfig base.TplName = "admin/config" +const ( + tplConfig base.TplName = "admin/config" + tplConfigSettings base.TplName = "admin/config_settings" +) // SendTestMail send test mail to confirm mail service is OK func SendTestMail(ctx *context.Context) { @@ -98,8 +101,9 @@ func shadowPassword(provider, cfgItem string) string { // Config show admin config page func Config(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("admin.config") + ctx.Data["Title"] = ctx.Tr("admin.config_summary") ctx.Data["PageIsAdminConfig"] = true + ctx.Data["PageIsAdminConfigSummary"] = true ctx.Data["CustomConf"] = setting.CustomConf ctx.Data["AppUrl"] = setting.AppURL @@ -161,23 +165,70 @@ func Config(ctx *context.Context) { ctx.Data["Loggers"] = log.GetManager().DumpLoggers() config.GetDynGetter().InvalidateCache() - ctx.Data["SystemConfig"] = setting.Config() prepareDeprecatedWarningsAlert(ctx) ctx.HTML(http.StatusOK, tplConfig) } +func ConfigSettings(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.config_settings") + ctx.Data["PageIsAdminConfig"] = true + ctx.Data["PageIsAdminConfigSettings"] = true + ctx.Data["DefaultOpenWithEditorAppsString"] = setting.DefaultOpenWithEditorApps().ToTextareaString() + ctx.HTML(http.StatusOK, tplConfigSettings) +} + func ChangeConfig(ctx *context.Context) { key := strings.TrimSpace(ctx.FormString("key")) value := ctx.FormString("value") cfg := setting.Config() - allowedKeys := container.SetOf(cfg.Picture.DisableGravatar.DynKey(), cfg.Picture.EnableFederatedAvatar.DynKey()) - if !allowedKeys.Contains(key) { + + marshalBool := func(v string) (string, error) { + if b, _ := strconv.ParseBool(v); b { + return "true", nil + } + return "false", nil + } + marshalOpenWithApps := func(value string) (string, error) { + lines := strings.Split(value, "\n") + var openWithEditorApps setting.OpenWithEditorAppsType + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + displayName, openURL, ok := strings.Cut(line, "=") + displayName, openURL = strings.TrimSpace(displayName), strings.TrimSpace(openURL) + if !ok || displayName == "" || openURL == "" { + continue + } + openWithEditorApps = append(openWithEditorApps, setting.OpenWithEditorApp{ + DisplayName: strings.TrimSpace(displayName), + OpenURL: strings.TrimSpace(openURL), + }) + } + b, err := json.Marshal(openWithEditorApps) + if err != nil { + return "", err + } + return string(b), nil + } + marshallers := map[string]func(string) (string, error){ + cfg.Picture.DisableGravatar.DynKey(): marshalBool, + cfg.Picture.EnableFederatedAvatar.DynKey(): marshalBool, + cfg.Repository.OpenWithEditorApps.DynKey(): marshalOpenWithApps, + } + marshaller, hasMarshaller := marshallers[key] + if !hasMarshaller { ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) return } - if err := system_model.SetSettings(ctx, map[string]string{key: value}); err != nil { - log.Error("set setting failed: %v", err) + marshaledValue, err := marshaller(value) + if err != nil { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + if err = system_model.SetSettings(ctx, map[string]string{key: marshaledValue}); err != nil { ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) return } diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 842d14232e..8ddfd92aa1 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -44,6 +44,7 @@ import ( repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/web/feed" @@ -812,7 +813,7 @@ func Home(ctx *context.Context) { return } - renderCode(ctx) + renderHomeCode(ctx) } // LastCommit returns lastCommit data for the provided branch/tag/commit and directory (in url) and filenames in body @@ -932,9 +933,33 @@ func renderRepoTopics(ctx *context.Context) { ctx.Data["Topics"] = topics } -func renderCode(ctx *context.Context) { +func prepareOpenWithEditorApps(ctx *context.Context) { + var tmplApps []map[string]any + apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx) + if len(apps) == 0 { + apps = setting.DefaultOpenWithEditorApps() + } + for _, app := range apps { + schema, _, _ := strings.Cut(app.OpenURL, ":") + var iconHTML template.HTML + if schema == "vscode" || schema == "vscodium" || schema == "jetbrains" { + iconHTML = svg.RenderHTML(fmt.Sprintf("gitea-open-with-%s", schema), 16, "gt-mr-3") + } else { + iconHTML = svg.RenderHTML("gitea-git", 16, "gt-mr-3") // TODO: it could support user's customized icon in the future + } + tmplApps = append(tmplApps, map[string]any{ + "DisplayName": app.DisplayName, + "OpenURL": app.OpenURL, + "IconHTML": iconHTML, + }) + } + ctx.Data["OpenWithEditorApps"] = tmplApps +} + +func renderHomeCode(ctx *context.Context) { ctx.Data["PageIsViewCode"] = true ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled + prepareOpenWithEditorApps(ctx) if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() { showEmpty := true diff --git a/routers/web/web.go b/routers/web/web.go index 2e42d7d63b..5cd7d112b0 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -691,6 +691,7 @@ func registerRoutes(m *web.Route) { m.Get("", admin.Config) m.Post("", admin.ChangeConfig) m.Post("/test_mail", admin.SendTestMail) + m.Get("/settings", admin.ConfigSettings) }) m.Group("/monitor", func() { diff --git a/services/context/context.go b/services/context/context.go index a06ebfb0dc..3e113e76ba 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -192,6 +192,7 @@ func Contexter() func(next http.Handler) http.Handler { httpcache.SetCacheControlInHeader(ctx.Resp.Header(), 0, "no-transform") ctx.Resp.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions) + ctx.Data["SystemConfig"] = setting.Config() ctx.Data["CsrfToken"] = ctx.Csrf.GetToken() ctx.Data["CsrfTokenHtml"] = template.HTML(``) diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl index ce6edf8a97..0c944fcb8f 100644 --- a/templates/admin/config.tmpl +++ b/templates/admin/config.tmpl @@ -285,27 +285,6 @@ -

- {{ctx.Locale.Tr "admin.config.picture_config"}} -

-
-
-
{{ctx.Locale.Tr "admin.config.disable_gravatar"}}
-
-
- -
-
-
-
{{ctx.Locale.Tr "admin.config.enable_federated_avatar"}}
-
-
- -
-
-
-
-

{{ctx.Locale.Tr "admin.config.git_config"}}

diff --git a/templates/admin/config_settings.tmpl b/templates/admin/config_settings.tmpl new file mode 100644 index 0000000000..22ad5c24ac --- /dev/null +++ b/templates/admin/config_settings.tmpl @@ -0,0 +1,42 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin config")}} +

+ {{ctx.Locale.Tr "admin.config.picture_config"}} +

+
+
+
{{ctx.Locale.Tr "admin.config.disable_gravatar"}}
+
+
+ +
+
+
+
{{ctx.Locale.Tr "admin.config.enable_federated_avatar"}}
+
+
+ +
+
+
+
+ +

+ {{ctx.Locale.Tr "repository"}} +

+
+
+
+
+ {{ctx.Locale.Tr "admin.config.open_with_editor_app_help"}} +
{{.DefaultOpenWithEditorAppsString}}
+
+
+
+ +
+
+ +
+
+
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index f23bdee124..16ec1b4b5b 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -77,9 +77,17 @@ {{end}} - - {{ctx.Locale.Tr "admin.config"}} - +
+ {{ctx.Locale.Tr "admin.config"}} + +
{{ctx.Locale.Tr "admin.notices"}} diff --git a/templates/repo/clone_script.tmpl b/templates/repo/clone_script.tmpl index 46e49e7f85..40dae76dc7 100644 --- a/templates/repo/clone_script.tmpl +++ b/templates/repo/clone_script.tmpl @@ -43,11 +43,8 @@ for (const el of document.getElementsByClassName('js-clone-url')) { el[el.nodeName === 'INPUT' ? 'value' : 'textContent'] = link; } - for (const el of document.getElementsByClassName('js-clone-url-vsc')) { - el['href'] = 'vscode://vscode.git/clone?url=' + encodeURIComponent(link); - } - for (const el of document.getElementsByClassName('js-clone-url-vscodium')) { - el['href'] = 'vscodium://vscode.git/clone?url=' + encodeURIComponent(link); + for (const el of document.getElementsByClassName('js-clone-url-editor')) { + el.href = el.getAttribute('data-href-template').replace('{url}', encodeURIComponent(link)); } })(); diff --git a/templates/repo/home.tmpl b/templates/repo/home.tmpl index 6f0a996841..1e157c5664 100644 --- a/templates/repo/home.tmpl +++ b/templates/repo/home.tmpl @@ -137,31 +137,16 @@ diff --git a/tests/integration/repo_test.go b/tests/integration/repo_test.go index 50022b6a29..5cf9816d22 100644 --- a/tests/integration/repo_test.go +++ b/tests/integration/repo_test.go @@ -54,84 +54,6 @@ func TestViewRepo(t *testing.T) { session.MakeRequest(t, req, http.StatusNotFound) } -func TestViewRepoCloneMethods(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - getCloneMethods := func() []string { - req := NewRequest(t, "GET", "/user2/repo1") - resp := MakeRequest(t, req, http.StatusOK) - - htmlDoc := NewHTMLParser(t, resp.Body) - cloneMoreMethodsHTML := htmlDoc.doc.Find("#more-btn div a") - - var methods []string - cloneMoreMethodsHTML.Each(func(i int, s *goquery.Selection) { - a, _ := s.Attr("href") - methods = append(methods, a) - }) - - return methods - } - - testCloneMethods := func(expected []string) { - methods := getCloneMethods() - - assert.Len(t, methods, len(expected)) - for i, expectedMethod := range expected { - assert.Contains(t, methods[i], expectedMethod) - } - } - - t.Run("Defaults", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - - testCloneMethods([]string{"/master.zip", "/master.tar.gz", "/master.bundle", "vscode://"}) - }) - - t.Run("Customized methods", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - defer test.MockVariableValue(&setting.Repository.DownloadOrCloneMethods, []string{"vscodium-clone", "download-targz"})() - - testCloneMethods([]string{"vscodium://", "/master.tar.gz"}) - }) - - t.Run("Individual methods", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - - singleMethodTest := func(method, expectedURLPart string) { - t.Run(method, func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - defer test.MockVariableValue(&setting.Repository.DownloadOrCloneMethods, []string{method})() - - testCloneMethods([]string{expectedURLPart}) - }) - } - - cases := map[string]string{ - "download-zip": "/master.zip", - "download-targz": "/master.tar.gz", - "download-bundle": "/master.bundle", - "vscode-clone": "vscode://", - "vscodium-clone": "vscodium://", - } - for method, expectedURLPart := range cases { - singleMethodTest(method, expectedURLPart) - } - }) - - t.Run("All methods", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - defer test.MockVariableValue(&setting.Repository.DownloadOrCloneMethods, setting.RecognisedRepositoryDownloadOrCloneMethods)() - - methods := getCloneMethods() - // We compare against - // len(setting.RecognisedRepositoryDownloadOrCloneMethods) - 1, because - // the test environment does not currently set things up for the cite - // method to display. - assert.GreaterOrEqual(t, len(methods), len(setting.RecognisedRepositoryDownloadOrCloneMethods)-1) - }) -} - func testViewRepo(t *testing.T) { defer tests.PrepareTestEnv(t)() @@ -1012,3 +934,64 @@ func TestRepoFollowSymlink(t *testing.T) { assertCase(t, "/user2/readme-test/src/branch/master/README.md", "", false) }) } + +func TestViewRepoOpenWith(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + getOpenWith := func() []string { + req := NewRequest(t, "GET", "/user2/repo1") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + openWithHTML := htmlDoc.doc.Find(".js-clone-url-editor") + + var methods []string + openWithHTML.Each(func(i int, s *goquery.Selection) { + a, _ := s.Attr("data-href-template") + methods = append(methods, a) + }) + + return methods + } + + testOpenWith := func(expected []string) { + methods := getOpenWith() + + assert.Len(t, methods, len(expected)) + for i, expectedMethod := range expected { + assert.True(t, strings.HasPrefix(methods[i], expectedMethod)) + } + } + + t.Run("Defaults", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testOpenWith([]string{"vscode://", "vscodium://", "jetbrains://"}) + }) + + t.Run("Customised", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Change the methods via the admin settings + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + session := loginUser(t, user.Name) + + setEditorApps := func(t *testing.T, apps string) { + t.Helper() + + req := NewRequestWithValues(t, "POST", "/admin/config?key=repository.open-with.editor-apps", map[string]string{ + "value": apps, + "_csrf": GetCSRF(t, session, "/admin/config/settings"), + }) + session.MakeRequest(t, req, http.StatusOK) + } + + defer func() { + setEditorApps(t, "") + }() + + setEditorApps(t, "test = test://?url={url}") + + testOpenWith([]string{"test://"}) + }) +} diff --git a/web_src/svg/gitea-open-with-jetbrains.svg b/web_src/svg/gitea-open-with-jetbrains.svg new file mode 100644 index 0000000000..a7884c4289 --- /dev/null +++ b/web_src/svg/gitea-open-with-jetbrains.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web_src/svg/gitea-vscode.svg b/web_src/svg/gitea-open-with-vscode.svg similarity index 100% rename from web_src/svg/gitea-vscode.svg rename to web_src/svg/gitea-open-with-vscode.svg diff --git a/web_src/svg/gitea-open-with-vscodium.svg b/web_src/svg/gitea-open-with-vscodium.svg new file mode 100644 index 0000000000..483676fe71 --- /dev/null +++ b/web_src/svg/gitea-open-with-vscodium.svg @@ -0,0 +1 @@ + \ No newline at end of file