Skip to content

Commit

Permalink
Customizable "Open with" applications for repository clone (#29320)
Browse files Browse the repository at this point in the history
Users could customize the "clone" menu with their own application URLs on the admin panel.

Replace #22378
Close #21121
Close #22149
  • Loading branch information
wxiaoguang authored Feb 24, 2024
1 parent c42083a commit 29a26d9
Show file tree
Hide file tree
Showing 19 changed files with 286 additions and 53 deletions.
1 change: 1 addition & 0 deletions modules/context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<input type="hidden" name="_csrf" value="` + ctx.Data["CsrfToken"].(string) + `">`)

Expand Down
49 changes: 46 additions & 3 deletions modules/setting/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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"),
},
}
}
Expand All @@ -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)
Expand Down
35 changes: 24 additions & 11 deletions modules/setting/config/value.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand All @@ -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}
}
5 changes: 4 additions & 1 deletion options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,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
open_with_editor = Open with %s
download_zip = Download ZIP
download_tar = Download TAR.GZ
download_bundle = Download BUNDLE
Expand Down Expand Up @@ -2737,6 +2737,8 @@ integrations = Integrations
authentication = Authentication Sources
emails = User Emails
config = Configuration
config_summary = Summary
config_settings = Settings
notices = System Notices
monitor = Monitoring
first_page = First
Expand Down Expand Up @@ -3176,6 +3178,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 Highlight
Expand Down
1 change: 1 addition & 0 deletions public/assets/img/svg/gitea-open-with-jetbrains.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions public/assets/img/svg/gitea-open-with-vscode.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions public/assets/img/svg/gitea-open-with-vscodium.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion public/assets/img/svg/gitea-vscode.svg

This file was deleted.

67 changes: 59 additions & 8 deletions routers/web/admin/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/context"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
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: value}); err != nil {
log.Error("set setting failed: %v", err)
if err = system_model.SetSettings(ctx, map[string]string{key: marshaledValue}); err != nil {
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
return
}
Expand Down
29 changes: 27 additions & 2 deletions routers/web/repo/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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"
Expand Down Expand Up @@ -792,7 +793,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
Expand Down Expand Up @@ -919,9 +920,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
Expand Down
1 change: 1 addition & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,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() {
Expand Down
21 changes: 0 additions & 21 deletions templates/admin/config.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -283,27 +283,6 @@
</dl>
</div>

<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.config.picture_config"}}
</h4>
<div class="ui attached table segment">
<dl class="admin-dl-horizontal">
<dt>{{ctx.Locale.Tr "admin.config.disable_gravatar"}}</dt>
<dd>
<div class="ui toggle checkbox" data-tooltip-content="{{ctx.Locale.Tr "admin.config.disable_gravatar"}}">
<input type="checkbox" data-config-dyn-key="picture.disable_gravatar" {{if .SystemConfig.Picture.DisableGravatar.Value ctx}}checked{{end}}>
</div>
</dd>
<div class="divider"></div>
<dt>{{ctx.Locale.Tr "admin.config.enable_federated_avatar"}}</dt>
<dd>
<div class="ui toggle checkbox" data-tooltip-content="{{ctx.Locale.Tr "admin.config.enable_federated_avatar"}}">
<input type="checkbox" data-config-dyn-key="picture.enable_federated_avatar" {{if .SystemConfig.Picture.EnableFederatedAvatar.Value ctx}}checked{{end}}>
</div>
</dd>
</dl>
</div>

<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.config.git_config"}}
</h4>
Expand Down
Loading

0 comments on commit 29a26d9

Please sign in to comment.