From 0387195abb82080b4c488966960f25a3e8c6fe66 Mon Sep 17 00:00:00 2001 From: Chai-Shi Date: Tue, 31 Dec 2024 12:22:09 +0800 Subject: [PATCH] [Feature] Private README.md for organization (#32872) Implemented #29503 --------- Co-authored-by: Ben Chang Co-authored-by: wxiaoguang --- modules/gitrepo/gitrepo.go | 17 ++-- modules/reqctx/datastore.go | 26 ++++- modules/templates/helper.go | 62 ++++++++---- modules/templates/helper_test.go | 55 +++++++++++ modules/util/path.go | 1 + options/locale/locale_en-US.ini | 8 +- routers/api/v1/repo/branch.go | 4 +- routers/api/v1/repo/compare.go | 2 +- routers/api/v1/repo/download.go | 2 +- routers/api/v1/repo/file.go | 2 +- routers/api/v1/repo/repo.go | 2 +- routers/private/internal_repo.go | 2 +- routers/web/org/home.go | 74 +++++++++------ routers/web/org/members.go | 4 +- routers/web/org/teams.go | 4 +- routers/web/shared/user/header.go | 86 +++++++++++------ routers/web/user/profile.go | 7 +- services/context/api.go | 2 +- services/context/base_form.go | 9 +- services/context/repo.go | 4 +- templates/org/home.tmpl | 27 +++++- templates/org/menu.tmpl | 4 +- templates/repo/create.tmpl | 9 +- templates/user/overview/header.tmpl | 2 +- tests/integration/org_profile_test.go | 132 ++++++++++++++++++++++++++ web_src/css/form.css | 44 ++++----- web_src/js/features/repo-new.ts | 42 +++++--- 27 files changed, 484 insertions(+), 149 deletions(-) create mode 100644 tests/integration/org_profile_test.go diff --git a/modules/gitrepo/gitrepo.go b/modules/gitrepo/gitrepo.go index 831b9d7bb7..540b724489 100644 --- a/modules/gitrepo/gitrepo.go +++ b/modules/gitrepo/gitrepo.go @@ -43,19 +43,20 @@ type contextKey struct { } // RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it +// The caller must call "defer gitRepo.Close()" func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) { - ds := reqctx.GetRequestDataStore(ctx) - if ds != nil { - gitRepo, err := RepositoryFromRequestContextOrOpen(ctx, ds, repo) + reqCtx := reqctx.FromContext(ctx) + if reqCtx != nil { + gitRepo, err := RepositoryFromRequestContextOrOpen(reqCtx, repo) return gitRepo, util.NopCloser{}, err } gitRepo, err := OpenRepository(ctx, repo) return gitRepo, gitRepo, err } -// RepositoryFromRequestContextOrOpen opens the repository at the given relative path in the provided request context -// The repo will be automatically closed when the request context is done -func RepositoryFromRequestContextOrOpen(ctx context.Context, ds reqctx.RequestDataStore, repo Repository) (*git.Repository, error) { +// RepositoryFromRequestContextOrOpen opens the repository at the given relative path in the provided request context. +// Caller shouldn't close the git repo manually, the git repo will be automatically closed when the request context is done. +func RepositoryFromRequestContextOrOpen(ctx reqctx.RequestContext, repo Repository) (*git.Repository, error) { ck := contextKey{repoPath: repoPath(repo)} if gitRepo, ok := ctx.Value(ck).(*git.Repository); ok { return gitRepo, nil @@ -64,7 +65,7 @@ func RepositoryFromRequestContextOrOpen(ctx context.Context, ds reqctx.RequestDa if err != nil { return nil, err } - ds.AddCloser(gitRepo) - ds.SetContextValue(ck, gitRepo) + ctx.AddCloser(gitRepo) + ctx.SetContextValue(ck, gitRepo) return gitRepo, nil } diff --git a/modules/reqctx/datastore.go b/modules/reqctx/datastore.go index 66361a4587..94232450f3 100644 --- a/modules/reqctx/datastore.go +++ b/modules/reqctx/datastore.go @@ -88,6 +88,21 @@ func (r *requestDataStore) cleanUp() { } } +type RequestContext interface { + context.Context + RequestDataStore +} + +func FromContext(ctx context.Context) RequestContext { + // here we must use the current ctx and the underlying store + // the current ctx guarantees that the ctx deadline/cancellation/values are respected + // the underlying store guarantees that the request-specific data is available + if store := GetRequestDataStore(ctx); store != nil { + return &requestContext{Context: ctx, RequestDataStore: store} + } + return nil +} + func GetRequestDataStore(ctx context.Context) RequestDataStore { if req, ok := ctx.Value(RequestDataStoreKey).(*requestDataStore); ok { return req @@ -97,11 +112,11 @@ func GetRequestDataStore(ctx context.Context) RequestDataStore { type requestContext struct { context.Context - dataStore *requestDataStore + RequestDataStore } func (c *requestContext) Value(key any) any { - if v := c.dataStore.GetContextValue(key); v != nil { + if v := c.GetContextValue(key); v != nil { return v } return c.Context.Value(key) @@ -109,9 +124,10 @@ func (c *requestContext) Value(key any) any { func NewRequestContext(parentCtx context.Context, profDesc string) (_ context.Context, finished func()) { ctx, _, processFinished := process.GetManager().AddTypedContext(parentCtx, profDesc, process.RequestProcessType, true) - reqCtx := &requestContext{Context: ctx, dataStore: &requestDataStore{values: make(map[any]any)}} + store := &requestDataStore{values: make(map[any]any)} + reqCtx := &requestContext{Context: ctx, RequestDataStore: store} return reqCtx, func() { - reqCtx.dataStore.cleanUp() + store.cleanUp() processFinished() } } @@ -119,5 +135,5 @@ func NewRequestContext(parentCtx context.Context, profDesc string) (_ context.Co // NewRequestContextForTest creates a new RequestContext for testing purposes // It doesn't add the context to the process manager, nor do cleanup func NewRequestContextForTest(parentCtx context.Context) context.Context { - return &requestContext{Context: parentCtx, dataStore: &requestDataStore{values: make(map[any]any)}} + return &requestContext{Context: parentCtx, RequestDataStore: &requestDataStore{values: make(map[any]any)}} } diff --git a/modules/templates/helper.go b/modules/templates/helper.go index b673450f5a..7529cadca4 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -264,22 +264,42 @@ func userThemeName(user *user_model.User) string { return setting.UI.DefaultTheme } +func isQueryParamEmpty(v any) bool { + return v == nil || v == false || v == 0 || v == int64(0) || v == "" +} + // QueryBuild builds a query string from a list of key-value pairs. -// It omits the nil and empty strings, but it doesn't omit other zero values, -// because the zero value of number types may have a meaning. +// It omits the nil, false, zero int/int64 and empty string values, +// because they are default empty values for "ctx.FormXxx" calls. +// If 0 or false need to be included, use string values: "0" and "false". +// Build rules: +// * Even parameters: always build as query string: a=b&c=d +// * Odd parameters: +// * * {"/anything", param-pairs...} => "/?param-paris" +// * * {"anything?old-params", new-param-pairs...} => "anything?old-params&new-param-paris" +// * * Otherwise: {"old¶ms", new-param-pairs...} => "old¶ms&new-param-paris" +// * * Other behaviors are undefined yet. func QueryBuild(a ...any) template.URL { - var s string + var reqPath, s string + hasTrailingSep := false if len(a)%2 == 1 { if v, ok := a[0].(string); ok { - if v == "" || (v[0] != '?' && v[0] != '&') { - panic("QueryBuild: invalid argument") - } s = v } else if v, ok := a[0].(template.URL); ok { s = string(v) } else { panic("QueryBuild: invalid argument") } + hasTrailingSep = s != "&" && strings.HasSuffix(s, "&") + if strings.HasPrefix(s, "/") || strings.Contains(s, "?") { + if s1, s2, ok := strings.Cut(s, "?"); ok { + reqPath = s1 + "?" + s = s2 + } else { + reqPath += s + "?" + s = "" + } + } } for i := len(a) % 2; i < len(a); i += 2 { k, ok := a[i].(string) @@ -290,19 +310,16 @@ func QueryBuild(a ...any) template.URL { if va, ok := a[i+1].(string); ok { v = va } else if a[i+1] != nil { - v = fmt.Sprint(a[i+1]) + if !isQueryParamEmpty(a[i+1]) { + v = fmt.Sprint(a[i+1]) + } } // pos1 to pos2 is the "k=v&" part, "&" is optional pos1 := strings.Index(s, "&"+k+"=") if pos1 != -1 { pos1++ - } else { - pos1 = strings.Index(s, "?"+k+"=") - if pos1 != -1 { - pos1++ - } else if strings.HasPrefix(s, k+"=") { - pos1 = 0 - } + } else if strings.HasPrefix(s, k+"=") { + pos1 = 0 } pos2 := len(s) if pos1 == -1 { @@ -315,7 +332,7 @@ func QueryBuild(a ...any) template.URL { } if v != "" { sep := "" - hasPrefixSep := pos1 == 0 || (pos1 <= len(s) && (s[pos1-1] == '?' || s[pos1-1] == '&')) + hasPrefixSep := pos1 == 0 || (pos1 <= len(s) && s[pos1-1] == '&') if !hasPrefixSep { sep = "&" } @@ -324,9 +341,22 @@ func QueryBuild(a ...any) template.URL { s = s[:pos1] + s[pos2:] } } - if s != "" && s != "&" && s[len(s)-1] == '&' { + if s != "" && s[len(s)-1] == '&' && !hasTrailingSep { s = s[:len(s)-1] } + if reqPath != "" { + if s == "" { + s = reqPath + if s != "?" { + s = s[:len(s)-1] + } + } else { + if s[0] == '&' { + s = s[1:] + } + s = reqPath + s + } + } return template.URL(s) } diff --git a/modules/templates/helper_test.go b/modules/templates/helper_test.go index a530d484bc..e35e8a28f8 100644 --- a/modules/templates/helper_test.go +++ b/modules/templates/helper_test.go @@ -118,3 +118,58 @@ func TestTemplateEscape(t *testing.T) { assert.Equal(t, `<>`, actual) }) } + +func TestQueryBuild(t *testing.T) { + t.Run("construct", func(t *testing.T) { + assert.Equal(t, "", string(QueryBuild())) + assert.Equal(t, "", string(QueryBuild("a", nil, "b", false, "c", 0, "d", ""))) + assert.Equal(t, "a=1&b=true", string(QueryBuild("a", 1, "b", "true"))) + + // path with query parameters + assert.Equal(t, "/?k=1", string(QueryBuild("/", "k", 1))) + assert.Equal(t, "/", string(QueryBuild("/?k=a", "k", 0))) + + // no path but question mark with query parameters + assert.Equal(t, "?k=1", string(QueryBuild("?", "k", 1))) + assert.Equal(t, "?", string(QueryBuild("?", "k", 0))) + assert.Equal(t, "path?k=1", string(QueryBuild("path?", "k", 1))) + assert.Equal(t, "path", string(QueryBuild("path?", "k", 0))) + + // only query parameters + assert.Equal(t, "&k=1", string(QueryBuild("&", "k", 1))) + assert.Equal(t, "", string(QueryBuild("&", "k", 0))) + assert.Equal(t, "", string(QueryBuild("&k=a", "k", 0))) + assert.Equal(t, "", string(QueryBuild("k=a&", "k", 0))) + assert.Equal(t, "a=1&b=2", string(QueryBuild("a=1", "b", 2))) + assert.Equal(t, "&a=1&b=2", string(QueryBuild("&a=1", "b", 2))) + assert.Equal(t, "a=1&b=2&", string(QueryBuild("a=1&", "b", 2))) + }) + + t.Run("replace", func(t *testing.T) { + assert.Equal(t, "a=1&c=d&e=f", string(QueryBuild("a=b&c=d&e=f", "a", 1))) + assert.Equal(t, "a=b&c=1&e=f", string(QueryBuild("a=b&c=d&e=f", "c", 1))) + assert.Equal(t, "a=b&c=d&e=1", string(QueryBuild("a=b&c=d&e=f", "e", 1))) + assert.Equal(t, "a=b&c=d&e=f&k=1", string(QueryBuild("a=b&c=d&e=f", "k", 1))) + }) + + t.Run("replace-&", func(t *testing.T) { + assert.Equal(t, "&a=1&c=d&e=f", string(QueryBuild("&a=b&c=d&e=f", "a", 1))) + assert.Equal(t, "&a=b&c=1&e=f", string(QueryBuild("&a=b&c=d&e=f", "c", 1))) + assert.Equal(t, "&a=b&c=d&e=1", string(QueryBuild("&a=b&c=d&e=f", "e", 1))) + assert.Equal(t, "&a=b&c=d&e=f&k=1", string(QueryBuild("&a=b&c=d&e=f", "k", 1))) + }) + + t.Run("delete", func(t *testing.T) { + assert.Equal(t, "c=d&e=f", string(QueryBuild("a=b&c=d&e=f", "a", ""))) + assert.Equal(t, "a=b&e=f", string(QueryBuild("a=b&c=d&e=f", "c", ""))) + assert.Equal(t, "a=b&c=d", string(QueryBuild("a=b&c=d&e=f", "e", ""))) + assert.Equal(t, "a=b&c=d&e=f", string(QueryBuild("a=b&c=d&e=f", "k", ""))) + }) + + t.Run("delete-&", func(t *testing.T) { + assert.Equal(t, "&c=d&e=f", string(QueryBuild("&a=b&c=d&e=f", "a", ""))) + assert.Equal(t, "&a=b&e=f", string(QueryBuild("&a=b&c=d&e=f", "c", ""))) + assert.Equal(t, "&a=b&c=d", string(QueryBuild("&a=b&c=d&e=f", "e", ""))) + assert.Equal(t, "&a=b&c=d&e=f", string(QueryBuild("&a=b&c=d&e=f", "k", ""))) + }) +} diff --git a/modules/util/path.go b/modules/util/path.go index 1272f5af2e..d4594947c9 100644 --- a/modules/util/path.go +++ b/modules/util/path.go @@ -203,6 +203,7 @@ func statDir(dirPath, recPath string, includeDir, isDirOnly, followSymlinks bool // // Slice does not include given path itself. // If subdirectories is enabled, they will have suffix '/'. +// FIXME: it doesn't like dot-files, for example: "owner/.profile.git" func StatDir(rootPath string, includeDir ...bool) ([]string, error) { if isDir, err := IsDir(rootPath); err != nil { return nil, err diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 16d894aa26..ef66e9ce45 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1015,7 +1015,9 @@ new_repo_helper = A repository contains all project files, including revision hi owner = Owner owner_helper = Some organizations may not show up in the dropdown due to a maximum repository count limit. repo_name = Repository Name -repo_name_helper = Good repository names use short, memorable and unique keywords. +repo_name_profile_public_hint= .profile is a special repository that you can use to add README.md to your public organization profile, visible to anyone. Make sure it’s public and initialize it with a README in the profile directory to get started. +repo_name_profile_private_hint = .profile-private is a special repository that you can use to add a README.md to your organization member profile, visible only to organization members. Make sure it’s private and initialize it with a README in the profile directory to get started. +repo_name_helper = Good repository names use short, memorable and unique keywords. A repository named '.profile' or '.profile-private' could be used to add a README.md for the user/organization profile. repo_size = Repository Size template = Template template_select = Select a template. @@ -2862,6 +2864,10 @@ teams.invite.title = You have been invited to join team %s in o teams.invite.by = Invited by %s teams.invite.description = Please click the button below to join the team. +view_as_role = View as: %s +view_as_public_hint = You are viewing the README a public user. +view_as_member_hint = You are viewing the README a member of this organization. + [admin] maintenance = Maintenance dashboard = Dashboard diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index 67dfda39a8..2fcdd02058 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -729,7 +729,7 @@ func CreateBranchProtection(ctx *context.APIContext) { } else { if !isPlainRule { if ctx.Repo.GitRepo == nil { - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return @@ -1057,7 +1057,7 @@ func EditBranchProtection(ctx *context.APIContext) { } else { if !isPlainRule { if ctx.Repo.GitRepo == nil { - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return diff --git a/routers/api/v1/repo/compare.go b/routers/api/v1/repo/compare.go index 87b890cb62..a1813a8a76 100644 --- a/routers/api/v1/repo/compare.go +++ b/routers/api/v1/repo/compare.go @@ -45,7 +45,7 @@ func CompareDiff(ctx *context.APIContext) { if ctx.Repo.GitRepo == nil { var err error - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return diff --git a/routers/api/v1/repo/download.go b/routers/api/v1/repo/download.go index eb967772ed..a8a23c4a8d 100644 --- a/routers/api/v1/repo/download.go +++ b/routers/api/v1/repo/download.go @@ -29,7 +29,7 @@ func DownloadArchive(ctx *context.APIContext) { if ctx.Repo.GitRepo == nil { var err error - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 8a4f78a3d7..1ad55d225b 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -282,7 +282,7 @@ func GetArchive(ctx *context.APIContext) { if ctx.Repo.GitRepo == nil { var err error - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index a192e241b7..ce09e7fc0f 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -726,7 +726,7 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err if ctx.Repo.GitRepo == nil && !repo.IsEmpty { var err error - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, repo) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo) if err != nil { ctx.Error(http.StatusInternalServerError, "Unable to OpenRepository", err) return err diff --git a/routers/private/internal_repo.go b/routers/private/internal_repo.go index 8a53e1ed23..e111d6689e 100644 --- a/routers/private/internal_repo.go +++ b/routers/private/internal_repo.go @@ -27,7 +27,7 @@ func RepoAssignment(ctx *gitea_context.PrivateContext) { return } - gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, repo) + gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo) if err != nil { log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err) ctx.JSON(http.StatusInternalServerError, private.Response{ diff --git a/routers/web/org/home.go b/routers/web/org/home.go index deeb18ae7c..277adb60ca 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/renderhelper" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" @@ -21,9 +22,7 @@ import ( "code.gitea.io/gitea/services/context" ) -const ( - tplOrgHome templates.TplName = "org/home" -) +const tplOrgHome templates.TplName = "org/home" // Home show organization home page func Home(ctx *context.Context) { @@ -110,15 +109,19 @@ func home(ctx *context.Context, viewRepositories bool) { ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0 - if !prepareOrgProfileReadme(ctx, viewRepositories) { - ctx.Data["PageIsViewRepositories"] = true + prepareResult, err := shared_user.PrepareOrgHeader(ctx) + if err != nil { + ctx.ServerError("PrepareOrgHeader", err) + return } - var ( - repos []*repo_model.Repository - count int64 - ) - repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + // if no profile readme, it still means "view repositories" + isViewOverview := !viewRepositories && prepareOrgProfileReadme(ctx, prepareResult) + ctx.Data["PageIsViewRepositories"] = !isViewOverview + ctx.Data["PageIsViewOverview"] = isViewOverview + ctx.Data["ShowOrgProfileReadmeSelector"] = isViewOverview && prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil + + repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ PageSize: setting.UI.User.RepoPagingNum, Page: page, @@ -151,28 +154,45 @@ func home(ctx *context.Context, viewRepositories bool) { ctx.HTML(http.StatusOK, tplOrgHome) } -func prepareOrgProfileReadme(ctx *context.Context, viewRepositories bool) bool { - profileDbRepo, profileGitRepo, profileReadme, profileClose := shared_user.FindUserProfileReadme(ctx, ctx.Doer) - defer profileClose() - ctx.Data["HasProfileReadme"] = profileReadme != nil +func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.PrepareOrgHeaderResult) bool { + viewAs := ctx.FormString("view_as", util.Iif(ctx.Org.IsMember, "member", "public")) + viewAsMember := viewAs == "member" - if profileGitRepo == nil || profileReadme == nil || viewRepositories { + var profileRepo *repo_model.Repository + var readmeBlob *git.Blob + if viewAsMember { + if prepareResult.ProfilePrivateReadmeBlob != nil { + profileRepo, readmeBlob = prepareResult.ProfilePrivateRepo, prepareResult.ProfilePrivateReadmeBlob + } else { + profileRepo, readmeBlob = prepareResult.ProfilePublicRepo, prepareResult.ProfilePublicReadmeBlob + viewAsMember = false + } + } else { + if prepareResult.ProfilePublicReadmeBlob != nil { + profileRepo, readmeBlob = prepareResult.ProfilePublicRepo, prepareResult.ProfilePublicReadmeBlob + } else { + profileRepo, readmeBlob = prepareResult.ProfilePrivateRepo, prepareResult.ProfilePrivateReadmeBlob + viewAsMember = true + } + } + if readmeBlob == nil { return false } - if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { - log.Error("failed to GetBlobContent: %v", err) - } else { - rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{ - CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), - }) - if profileContent, err := markdown.RenderString(rctx, bytes); err != nil { - log.Error("failed to RenderString: %v", err) - } else { - ctx.Data["ProfileReadme"] = profileContent - } + readmeBytes, err := readmeBlob.GetBlobContent(setting.UI.MaxDisplayFileSize) + if err != nil { + log.Error("failed to GetBlobContent for profile %q (view as %q) readme: %v", profileRepo.FullName(), viewAs, err) + return false } - ctx.Data["PageIsViewOverview"] = true + rctx := renderhelper.NewRenderContextRepoFile(ctx, profileRepo, renderhelper.RepoFileOptions{ + CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileRepo.DefaultBranch)), + }) + ctx.Data["ProfileReadmeContent"], err = markdown.RenderString(rctx, readmeBytes) + if err != nil { + log.Error("failed to GetBlobContent for profile %q (view as %q) readme: %v", profileRepo.FullName(), viewAs, err) + return false + } + ctx.Data["IsViewingOrgAsMember"] = viewAsMember return true } diff --git a/routers/web/org/members.go b/routers/web/org/members.go index 5a134caecb..1665a12302 100644 --- a/routers/web/org/members.go +++ b/routers/web/org/members.go @@ -54,9 +54,9 @@ func Members(ctx *context.Context) { return } - err = shared_user.RenderOrgHeader(ctx) + _, err = shared_user.PrepareOrgHeader(ctx) if err != nil { - ctx.ServerError("RenderOrgHeader", err) + ctx.ServerError("PrepareOrgHeader", err) return } diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index 0137f2cc96..26031029d6 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -58,9 +58,9 @@ func Teams(ctx *context.Context) { } ctx.Data["Teams"] = ctx.Org.Teams - err := shared_user.RenderOrgHeader(ctx) + _, err := shared_user.PrepareOrgHeader(ctx) if err != nil { - ctx.ServerError("RenderOrgHeader", err) + ctx.ServerError("PrepareOrgHeader", err) return } diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index d388d2b5d9..62b146c7f3 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" ) @@ -102,37 +103,46 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { } } -func FindUserProfileReadme(ctx *context.Context, doer *user_model.User) (profileDbRepo *repo_model.Repository, profileGitRepo *git.Repository, profileReadmeBlob *git.Blob, profileClose func()) { - profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ".profile") - if err == nil { - perm, err := access_model.GetUserRepoPermission(ctx, profileDbRepo, doer) - if err == nil && !profileDbRepo.IsEmpty && perm.CanRead(unit.TypeCode) { - if profileGitRepo, err = gitrepo.OpenRepository(ctx, profileDbRepo); err != nil { - log.Error("FindUserProfileReadme failed to OpenRepository: %v", err) - } else { - if commit, err := profileGitRepo.GetBranchCommit(profileDbRepo.DefaultBranch); err != nil { - log.Error("FindUserProfileReadme failed to GetBranchCommit: %v", err) - } else { - profileReadmeBlob, _ = commit.GetBlobByPath("README.md") - } - } +func FindOwnerProfileReadme(ctx *context.Context, doer *user_model.User, optProfileRepoName ...string) (profileDbRepo *repo_model.Repository, profileReadmeBlob *git.Blob) { + profileRepoName := util.OptionalArg(optProfileRepoName, RepoNameProfile) + profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, profileRepoName) + if err != nil { + if !repo_model.IsErrRepoNotExist(err) { + log.Error("FindOwnerProfileReadme failed to GetRepositoryByName: %v", err) } - } else if !repo_model.IsErrRepoNotExist(err) { - log.Error("FindUserProfileReadme failed to GetRepositoryByName: %v", err) + return nil, nil } - return profileDbRepo, profileGitRepo, profileReadmeBlob, func() { - if profileGitRepo != nil { - _ = profileGitRepo.Close() - } + + perm, err := access_model.GetUserRepoPermission(ctx, profileDbRepo, doer) + if err != nil { + log.Error("FindOwnerProfileReadme failed to GetRepositoryByName: %v", err) + return nil, nil } + if profileDbRepo.IsEmpty || !perm.CanRead(unit.TypeCode) { + return nil, nil + } + + profileGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, profileDbRepo) + if err != nil { + log.Error("FindOwnerProfileReadme failed to OpenRepository: %v", err) + return nil, nil + } + + commit, err := profileGitRepo.GetBranchCommit(profileDbRepo.DefaultBranch) + if err != nil { + log.Error("FindOwnerProfileReadme failed to GetBranchCommit: %v", err) + return nil, nil + } + + profileReadmeBlob, _ = commit.GetBlobByPath("README.md") // no need to handle this error + return profileDbRepo, profileReadmeBlob } func RenderUserHeader(ctx *context.Context) { prepareContextForCommonProfile(ctx) - _, _, profileReadmeBlob, profileClose := FindUserProfileReadme(ctx, ctx.Doer) - defer profileClose() - ctx.Data["HasProfileReadme"] = profileReadmeBlob != nil + _, profileReadmeBlob := FindOwnerProfileReadme(ctx, ctx.Doer) + ctx.Data["HasUserProfileReadme"] = profileReadmeBlob != nil } func LoadHeaderCount(ctx *context.Context) error { @@ -169,14 +179,28 @@ func LoadHeaderCount(ctx *context.Context) error { return nil } -func RenderOrgHeader(ctx *context.Context) error { - if err := LoadHeaderCount(ctx); err != nil { - return err +const ( + RepoNameProfilePrivate = ".profile-private" + RepoNameProfile = ".profile" +) + +type PrepareOrgHeaderResult struct { + ProfilePublicRepo *repo_model.Repository + ProfilePublicReadmeBlob *git.Blob + ProfilePrivateRepo *repo_model.Repository + ProfilePrivateReadmeBlob *git.Blob + HasOrgProfileReadme bool +} + +func PrepareOrgHeader(ctx *context.Context) (result *PrepareOrgHeaderResult, err error) { + if err = LoadHeaderCount(ctx); err != nil { + return nil, err } - _, _, profileReadmeBlob, profileClose := FindUserProfileReadme(ctx, ctx.Doer) - defer profileClose() - ctx.Data["HasProfileReadme"] = profileReadmeBlob != nil - - return nil + result = &PrepareOrgHeaderResult{} + result.ProfilePublicRepo, result.ProfilePublicReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer) + result.ProfilePrivateRepo, result.ProfilePrivateReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer, RepoNameProfilePrivate) + result.HasOrgProfileReadme = result.ProfilePublicReadmeBlob != nil || result.ProfilePrivateReadmeBlob != nil + ctx.Data["HasOrgProfileReadme"] = result.HasOrgProfileReadme // many pages need it to show the "overview" tab + return result, nil } diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 9c014bffdb..006ffdcf7e 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -74,8 +74,7 @@ func userProfile(ctx *context.Context) { ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data) } - profileDbRepo, _ /*profileGitRepo*/, profileReadmeBlob, profileClose := shared_user.FindUserProfileReadme(ctx, ctx.Doer) - defer profileClose() + profileDbRepo, profileReadmeBlob := shared_user.FindOwnerProfileReadme(ctx, ctx.Doer) showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) prepareUserProfileTabData(ctx, showPrivate, profileDbRepo, profileReadmeBlob) @@ -96,7 +95,7 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb } } ctx.Data["TabName"] = tab - ctx.Data["HasProfileReadme"] = profileReadme != nil + ctx.Data["HasUserProfileReadme"] = profileReadme != nil page := ctx.FormInt("page") if page <= 0 { @@ -254,7 +253,7 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb if profileContent, err := markdown.RenderString(rctx, bytes); err != nil { log.Error("failed to RenderString: %v", err) } else { - ctx.Data["ProfileReadme"] = profileContent + ctx.Data["ProfileReadmeContent"] = profileContent } } case "organizations": diff --git a/services/context/api.go b/services/context/api.go index 7b604c5ea1..bda705cb48 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -274,7 +274,7 @@ func ReferencesGitRepo(allowEmpty ...bool) func(ctx *APIContext) { // For API calls. if ctx.Repo.GitRepo == nil { var err error - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Open Repository %v failed", ctx.Repo.Repository.FullName()), err) return diff --git a/services/context/base_form.go b/services/context/base_form.go index ddf9734f57..5b8cae9e99 100644 --- a/services/context/base_form.go +++ b/services/context/base_form.go @@ -8,11 +8,16 @@ import ( "strings" "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/util" ) // FormString returns the first value matching the provided key in the form as a string -func (b *Base) FormString(key string) string { - return b.Req.FormValue(key) +func (b *Base) FormString(key string, def ...string) string { + s := b.Req.FormValue(key) + if s == "" { + s = util.OptionalArg(def) + } + return s } // FormStrings returns a string slice for the provided key from the form diff --git a/services/context/repo.go b/services/context/repo.go index b537a05036..2a473f4a54 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -622,7 +622,7 @@ func RepoAssignment(ctx *Context) { ctx.Repo.GitRepo = nil } - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, repo) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo) if err != nil { if strings.Contains(err.Error(), "repository does not exist") || strings.Contains(err.Error(), "no such file or directory") { log.Error("Repository %-v has a broken repository on the file system: %s Error: %v", ctx.Repo.Repository, ctx.Repo.Repository.RepoPath(), err) @@ -881,7 +881,7 @@ func RepoRefByType(detectRefType RepoRefType, opts ...RepoRefByTypeOptions) func ) if ctx.Repo.GitRepo == nil { - ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx, ctx.Repo.Repository) + ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) if err != nil { ctx.ServerError(fmt.Sprintf("Open Repository %v failed", ctx.Repo.Repository.FullName()), err) return diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index 4851b69979..db750692bf 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -5,8 +5,8 @@
- {{if .ProfileReadme}} -
{{.ProfileReadme}}
+ {{if .ProfileReadmeContent}} +
{{.ProfileReadmeContent}}
{{end}} {{template "shared/repo_search" .}} {{template "explore/repo_list" .}} @@ -24,6 +24,29 @@
{{end}} + + {{if and .ShowMemberAndTeamTab .ShowOrgProfileReadmeSelector}} +
+ +
+ {{if .IsViewingOrgAsMember}}{{ctx.Locale.Tr "org.view_as_member_hint"}}{{else}}{{ctx.Locale.Tr "org.view_as_public_hint"}}{{end}} +
+
+ {{end}} + {{if .NumMembers}}

{{ctx.Locale.Tr "org.members"}} diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl index 29238f8d6b..4a8aee68a7 100644 --- a/templates/org/menu.tmpl +++ b/templates/org/menu.tmpl @@ -1,12 +1,12 @@
- {{if .HasProfileReadme}} + {{if .HasOrgProfileReadme}} {{svg "octicon-info"}} {{ctx.Locale.Tr "user.overview"}} {{end}} - + {{svg "octicon-repo"}} {{ctx.Locale.Tr "user.repositories"}} {{if .RepoCount}}
{{.RepoCount}}
diff --git a/templates/repo/create.tmpl b/templates/repo/create.tmpl index 2e1de244ea..78eb2f704a 100644 --- a/templates/repo/create.tmpl +++ b/templates/repo/create.tmpl @@ -1,8 +1,8 @@ {{template "base/head" .}} -
+
-
+ {{.CsrfTokenHtml}}

{{ctx.Locale.Tr "new_repo"}} @@ -44,8 +44,11 @@
- {{ctx.Locale.Tr "repo.repo_name_helper"}} + {{ctx.Locale.Tr "repo.repo_name_helper"}} + {{ctx.Locale.Tr "repo.repo_name_profile_public_hint"}} + {{ctx.Locale.Tr "repo.repo_name_profile_private_hint"}}
+
diff --git a/templates/user/overview/header.tmpl b/templates/user/overview/header.tmpl index 275c4e295e..f4664c704d 100644 --- a/templates/user/overview/header.tmpl +++ b/templates/user/overview/header.tmpl @@ -1,6 +1,6 @@
- {{if and .HasProfileReadme .ContextUser.IsIndividual}} + {{if and .HasUserProfileReadme .ContextUser.IsIndividual}} {{svg "octicon-info"}} {{ctx.Locale.Tr "user.overview"}} diff --git a/tests/integration/org_profile_test.go b/tests/integration/org_profile_test.go new file mode 100644 index 0000000000..73cafd85c2 --- /dev/null +++ b/tests/integration/org_profile_test.go @@ -0,0 +1,132 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/shared/user" + + "github.com/stretchr/testify/assert" +) + +func getCreateProfileReadmeFileOptions(content string) api.CreateFileOptions { + contentEncoded := base64.StdEncoding.EncodeToString([]byte(content)) + return api.CreateFileOptions{ + FileOptions: api.FileOptions{ + BranchName: "main", + NewBranchName: "main", + Message: "create the profile README.md", + Dates: api.CommitDateOptions{ + Author: time.Unix(946684810, 0), + Committer: time.Unix(978307190, 0), + }, + }, + ContentBase64: contentEncoded, + } +} + +func createTestProfile(t *testing.T, orgName, profileRepoName, readmeContent string) { + isPrivate := profileRepoName == user.RepoNameProfilePrivate + + ctx := NewAPITestContext(t, "user1", profileRepoName, auth_model.AccessTokenScopeAll) + session := loginUser(t, "user1") + tokenAdmin := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) + + // create repo + doAPICreateOrganizationRepository(ctx, orgName, &api.CreateRepoOption{Name: profileRepoName, Private: isPrivate})(t) + + // create readme + createFileOptions := getCreateProfileReadmeFileOptions(readmeContent) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", orgName, profileRepoName, "README.md"), &createFileOptions). + AddTokenAuth(tokenAdmin) + MakeRequest(t, req, http.StatusCreated) +} + +func TestOrgProfile(t *testing.T) { + onGiteaRun(t, testOrgProfile) +} + +func testOrgProfile(t *testing.T, u *url.URL) { + const contentPublicReadme = "Public Readme Content" + const contentPrivateReadme = "Private Readme Content" + // HTML: "#org-home-view-as-dropdown" (indicate whether the view as dropdown menu is present) + + // PART 1: Test Both Private and Public + createTestProfile(t, "org3", user.RepoNameProfile, contentPublicReadme) + createTestProfile(t, "org3", user.RepoNameProfilePrivate, contentPrivateReadme) + + // Anonymous User + req := NewRequest(t, "GET", "org3") + resp := MakeRequest(t, req, http.StatusOK) + bodyString := util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPublicReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) + + // Logged in but not member + session := loginUser(t, "user24") + req = NewRequest(t, "GET", "org3") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPublicReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) + + // Site Admin + session = loginUser(t, "user1") + req = NewRequest(t, "GET", "/org3") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPrivateReadme) // as an org member, default to show the private profile + assert.Contains(t, bodyString, `id="org-home-view-as-dropdown"`) + + req = NewRequest(t, "GET", "/org3?view_as=member") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPrivateReadme) + assert.Contains(t, bodyString, `id="org-home-view-as-dropdown"`) + + req = NewRequest(t, "GET", "/org3?view_as=public") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPublicReadme) + assert.Contains(t, bodyString, `id="org-home-view-as-dropdown"`) + + // PART 2: Each org has either one of private pr public profile + createTestProfile(t, "org41", user.RepoNameProfile, contentPublicReadme) + createTestProfile(t, "org42", user.RepoNameProfilePrivate, contentPrivateReadme) + + // Anonymous User + req = NewRequest(t, "GET", "/org41") + resp = MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPublicReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) + + req = NewRequest(t, "GET", "/org42") + resp = MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.NotContains(t, bodyString, contentPrivateReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) + + // Site Admin + req = NewRequest(t, "GET", "/org41") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPublicReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) + + req = NewRequest(t, "GET", "/org42") + resp = session.MakeRequest(t, req, http.StatusOK) + bodyString = util.UnsafeBytesToString(resp.Body.Bytes()) + assert.Contains(t, bodyString, contentPrivateReadme) + assert.NotContains(t, bodyString, `id="org-home-view-as-dropdown"`) +} diff --git a/web_src/css/form.css b/web_src/css/form.css index 5dd5e05bec..a92ba354b4 100644 --- a/web_src/css/form.css +++ b/web_src/css/form.css @@ -325,50 +325,50 @@ textarea:focus, margin: 0; } -.repository.new.repo form, +.repository.new-repo form, .repository.new.migrate form, .repository.new.fork form { margin: auto; } -.repository.new.repo form .ui.message, +.repository.new-repo form .ui.message, .repository.new.migrate form .ui.message, .repository.new.fork form .ui.message { text-align: center; } @media (min-width: 768px) { - .repository.new.repo form, + .repository.new-repo form, .repository.new.migrate form, .repository.new.fork form { width: 800px !important; } - .repository.new.repo form .header, + .repository.new-repo form .header, .repository.new.migrate form .header, .repository.new.fork form .header { padding-left: 280px !important; } - .repository.new.repo form .inline.field > label, + .repository.new-repo form .inline.field > label, .repository.new.migrate form .inline.field > label, .repository.new.fork form .inline.field > label { text-align: right; width: 250px !important; word-wrap: break-word; } - .repository.new.repo form .help, + .repository.new-repo form .help, .repository.new.migrate form .help, .repository.new.fork form .help { margin-left: 265px !important; } - .repository.new.repo form .optional .title, + .repository.new-repo form .optional .title, .repository.new.migrate form .optional .title, .repository.new.fork form .optional .title { margin-left: 250px !important; } - .repository.new.repo form .inline.field > input, + .repository.new-repo form .inline.field > input, .repository.new.migrate form .inline.field > input, .repository.new.fork form .inline.field > input, - .repository.new.repo form .inline.field > textarea, + .repository.new-repo form .inline.field > textarea, .repository.new.migrate form .inline.field > textarea, .repository.new.fork form .inline.field > textarea { width: 50%; @@ -376,32 +376,32 @@ textarea:focus, } @media (max-width: 767.98px) { - .repository.new.repo form .optional .title, + .repository.new-repo form .optional .title, .repository.new.migrate form .optional .title, .repository.new.fork form .optional .title { margin-left: 15px; } - .repository.new.repo form .inline.field > label, + .repository.new-repo form .inline.field > label, .repository.new.migrate form .inline.field > label, .repository.new.fork form .inline.field > label { display: block; } } -.repository.new.repo form .dropdown .text, +.repository.new-repo form .dropdown .text, .repository.new.migrate form .dropdown .text, .repository.new.fork form .dropdown .text { margin-right: 0 !important; } -.repository.new.repo form .header, +.repository.new-repo form .header, .repository.new.migrate form .header, .repository.new.fork form .header { padding-left: 0 !important; text-align: center; } -.repository.new.repo form .selection.dropdown, +.repository.new-repo form .selection.dropdown, .repository.new.migrate form .selection.dropdown, .repository.new.fork form .selection.dropdown, .repository.new.fork form .field a { @@ -410,22 +410,22 @@ textarea:focus, } @media (max-width: 767.98px) { - .repository.new.repo form label, + .repository.new-repo form label, .repository.new.migrate form label, .repository.new.fork form label, - .repository.new.repo form .inline.field > input, + .repository.new-repo form .inline.field > input, .repository.new.migrate form .inline.field > input, .repository.new.fork form .inline.field > input, .repository.new.fork form .field a, - .repository.new.repo form .selection.dropdown, + .repository.new-repo form .selection.dropdown, .repository.new.migrate form .selection.dropdown, .repository.new.fork form .selection.dropdown { width: 100% !important; } - .repository.new.repo form .field button, + .repository.new-repo form .field button, .repository.new.migrate form .field button, .repository.new.fork form .field button, - .repository.new.repo form .field a, + .repository.new-repo form .field a, .repository.new.migrate form .field a { margin-bottom: 1em; width: 100%; @@ -433,17 +433,17 @@ textarea:focus, } @media (min-width: 768px) { - .repository.new.repo .ui.form #auto-init { + .repository.new-repo .ui.form #auto-init { margin-left: 265px !important; } } -.repository.new.repo .ui.form .selection.dropdown:not(.owner) { +.repository.new-repo .ui.form .selection.dropdown:not(.owner) { width: 50% !important; } @media (max-width: 767.98px) { - .repository.new.repo .ui.form .selection.dropdown:not(.owner) { + .repository.new-repo .ui.form .selection.dropdown:not(.owner) { width: 100% !important; } } diff --git a/web_src/js/features/repo-new.ts b/web_src/js/features/repo-new.ts index 436288325a..ec44a14ce0 100644 --- a/web_src/js/features/repo-new.ts +++ b/web_src/js/features/repo-new.ts @@ -1,14 +1,34 @@ -import $ from 'jquery'; +import {hideElem, showElem} from '../utils/dom.ts'; export function initRepoNew() { - // Repo Creation - if ($('.repository.new.repo').length > 0) { - $('input[name="gitignores"], input[name="license"]').on('change', () => { - const gitignores = $('input[name="gitignores"]').val(); - const license = $('input[name="license"]').val(); - if (gitignores || license) { - document.querySelector('input[name="auto_init"]').checked = true; - } - }); - } + const pageContent = document.querySelector('.page-content.repository.new-repo'); + if (!pageContent) return; + + const form = document.querySelector('.new-repo-form'); + const inputGitIgnores = form.querySelector('input[name="gitignores"]'); + const inputLicense = form.querySelector('input[name="license"]'); + const inputAutoInit = form.querySelector('input[name="auto_init"]'); + const updateUiAutoInit = () => { + inputAutoInit.checked = Boolean(inputGitIgnores.value || inputLicense.value); + }; + form.addEventListener('change', updateUiAutoInit); + updateUiAutoInit(); + + const inputRepoName = form.querySelector('input[name="repo_name"]'); + const inputPrivate = form.querySelector('input[name="private"]'); + const updateUiRepoName = () => { + const helps = form.querySelectorAll(`.help[data-help-for-repo-name]`); + hideElem(helps); + let help = form.querySelector(`.help[data-help-for-repo-name="${CSS.escape(inputRepoName.value)}"]`); + if (!help) help = form.querySelector(`.help[data-help-for-repo-name=""]`); + showElem(help); + const repoNamePreferPrivate = {'.profile': false, '.profile-private': true}; + const preferPrivate = repoNamePreferPrivate[inputRepoName.value]; + // inputPrivate might be disabled because site admin "force private" + if (preferPrivate !== undefined && !inputPrivate.closest('.disabled, [disabled]')) { + inputPrivate.checked = preferPrivate; + } + }; + inputRepoName.addEventListener('input', updateUiRepoName); + updateUiRepoName(); }