WIP add sourcegraph
This commit is contained in:
@@ -217,6 +217,7 @@ func LoadSettings() {
|
||||
loadProjectFrom(CfgProvider)
|
||||
loadMimeTypeMapFrom(CfgProvider)
|
||||
loadFederationFrom(CfgProvider)
|
||||
loadSourcegraphFrom(CfgProvider)
|
||||
}
|
||||
|
||||
// LoadSettingsForInstall initializes the settings for install
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
// Sourcegraph settings for code intelligence integration
|
||||
var Sourcegraph = struct {
|
||||
Enabled bool
|
||||
URL string
|
||||
AccessToken string
|
||||
SkipTLSVerify bool
|
||||
Timeout int // request timeout in seconds
|
||||
CacheTTL int // cache time-to-live in seconds
|
||||
SyncOnPush bool
|
||||
}{
|
||||
Enabled: false,
|
||||
Timeout: 5,
|
||||
CacheTTL: 300,
|
||||
SyncOnPush: true,
|
||||
SkipTLSVerify: false,
|
||||
}
|
||||
|
||||
func loadSourcegraphFrom(rootCfg ConfigProvider) {
|
||||
sec := rootCfg.Section("sourcegraph")
|
||||
Sourcegraph.Enabled = sec.Key("ENABLED").MustBool(false)
|
||||
Sourcegraph.URL = sec.Key("URL").MustString("")
|
||||
Sourcegraph.AccessToken = sec.Key("ACCESS_TOKEN").MustString("")
|
||||
Sourcegraph.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool(false)
|
||||
Sourcegraph.Timeout = sec.Key("TIMEOUT").MustInt(5)
|
||||
Sourcegraph.CacheTTL = sec.Key("CACHE_TTL").MustInt(300)
|
||||
Sourcegraph.SyncOnPush = sec.Key("SYNC_ON_PUSH").MustBool(true)
|
||||
|
||||
// Validate: if enabled, URL must be set
|
||||
if Sourcegraph.Enabled && Sourcegraph.URL == "" {
|
||||
Sourcegraph.Enabled = false
|
||||
}
|
||||
}
|
||||
@@ -1443,6 +1443,11 @@ func Routes() *web.Router {
|
||||
m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig)
|
||||
m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages)
|
||||
m.Get("/licenses", reqRepoReader(unit.TypeCode), repo.GetLicenses)
|
||||
m.Group("/sourcegraph", func() {
|
||||
m.Get("/hover", repo.SourcegraphHover)
|
||||
m.Get("/definition", repo.SourcegraphDefinition)
|
||||
m.Get("/references", repo.SourcegraphReferences)
|
||||
}, reqRepoReader(unit.TypeCode))
|
||||
m.Get("/activities/feeds", repo.ListRepoActivityFeeds)
|
||||
m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed)
|
||||
m.Group("/avatar", func() {
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
sourcegraph_service "code.gitea.io/gitea/services/sourcegraph"
|
||||
)
|
||||
|
||||
// SourcegraphHover returns hover information at a position
|
||||
func SourcegraphHover(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/sourcegraph/hover repository repoSourcegraphHover
|
||||
// ---
|
||||
// summary: Get code intelligence hover info
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: path
|
||||
// in: query
|
||||
// description: file path
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: line
|
||||
// in: query
|
||||
// description: line number (0-indexed)
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: character
|
||||
// in: query
|
||||
// description: character position (0-indexed)
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: query
|
||||
// description: git ref (commit SHA, branch, tag)
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// description: hover information
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "503":
|
||||
// description: Sourcegraph integration not available
|
||||
|
||||
if !setting.Sourcegraph.Enabled {
|
||||
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph integration is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
path := ctx.FormString("path")
|
||||
line := ctx.FormInt("line")
|
||||
char := ctx.FormInt("character")
|
||||
ref := ctx.FormString("ref")
|
||||
|
||||
if path == "" || ref == "" {
|
||||
ctx.APIError(http.StatusBadRequest, "path and ref are required")
|
||||
return
|
||||
}
|
||||
|
||||
client := sourcegraph_service.GetClient()
|
||||
if client == nil {
|
||||
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := client.Hover(ctx, ctx.Repo.Repository.FullName(), ref, path, line, char)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
ctx.JSON(http.StatusOK, map[string]any{})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SourcegraphDefinition returns definition locations for a symbol
|
||||
func SourcegraphDefinition(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/sourcegraph/definition repository repoSourcegraphDefinition
|
||||
// ---
|
||||
// summary: Get code intelligence definition locations
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: path
|
||||
// in: query
|
||||
// description: file path
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: line
|
||||
// in: query
|
||||
// description: line number (0-indexed)
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: character
|
||||
// in: query
|
||||
// description: character position (0-indexed)
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: query
|
||||
// description: git ref (commit SHA, branch, tag)
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// description: definition locations
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "503":
|
||||
// description: Sourcegraph integration not available
|
||||
|
||||
if !setting.Sourcegraph.Enabled {
|
||||
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph integration is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
path := ctx.FormString("path")
|
||||
line := ctx.FormInt("line")
|
||||
char := ctx.FormInt("character")
|
||||
ref := ctx.FormString("ref")
|
||||
|
||||
if path == "" || ref == "" {
|
||||
ctx.APIError(http.StatusBadRequest, "path and ref are required")
|
||||
return
|
||||
}
|
||||
|
||||
client := sourcegraph_service.GetClient()
|
||||
if client == nil {
|
||||
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := client.Definition(ctx, ctx.Repo.Repository.FullName(), ref, path, line, char)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
result = []sourcegraph_service.Location{}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SourcegraphReferences returns reference locations for a symbol
|
||||
func SourcegraphReferences(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/sourcegraph/references repository repoSourcegraphReferences
|
||||
// ---
|
||||
// summary: Get code intelligence reference locations
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: path
|
||||
// in: query
|
||||
// description: file path
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: line
|
||||
// in: query
|
||||
// description: line number (0-indexed)
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: character
|
||||
// in: query
|
||||
// description: character position (0-indexed)
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: query
|
||||
// description: git ref (commit SHA, branch, tag)
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// description: reference locations
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "503":
|
||||
// description: Sourcegraph integration not available
|
||||
|
||||
if !setting.Sourcegraph.Enabled {
|
||||
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph integration is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
path := ctx.FormString("path")
|
||||
line := ctx.FormInt("line")
|
||||
char := ctx.FormInt("character")
|
||||
ref := ctx.FormString("ref")
|
||||
|
||||
if path == "" || ref == "" {
|
||||
ctx.APIError(http.StatusBadRequest, "path and ref are required")
|
||||
return
|
||||
}
|
||||
|
||||
client := sourcegraph_service.GetClient()
|
||||
if client == nil {
|
||||
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := client.References(ctx, ctx.Repo.Repository.FullName(), ref, path, line, char)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
result = []sourcegraph_service.Location{}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import (
|
||||
release_service "code.gitea.io/gitea/services/release"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
"code.gitea.io/gitea/services/repository/archiver"
|
||||
sourcegraph_service "code.gitea.io/gitea/services/sourcegraph"
|
||||
"code.gitea.io/gitea/services/task"
|
||||
"code.gitea.io/gitea/services/uinotification"
|
||||
"code.gitea.io/gitea/services/webhook"
|
||||
@@ -172,6 +173,7 @@ func InitWebInstalled(ctx context.Context) {
|
||||
mustInitCtx(ctx, actions_service.Init)
|
||||
|
||||
mustInit(repo_service.InitLicenseClassifier)
|
||||
mustInit(sourcegraph_service.Init)
|
||||
|
||||
// Finally start up the cron
|
||||
cron.Init(ctx)
|
||||
|
||||
@@ -281,4 +281,14 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa
|
||||
ctx.Data["EscapeStatus"] = escapeStatus
|
||||
ctx.Data["BlameRows"] = rows
|
||||
ctx.Data["LexerName"] = lexerName
|
||||
|
||||
// Sourcegraph code intelligence configuration
|
||||
if setting.Sourcegraph.Enabled {
|
||||
ctx.Data["SourcegraphEnabled"] = true
|
||||
ctx.Data["SourcegraphConfig"] = map[string]any{
|
||||
"enabled": true,
|
||||
"repoFullName": ctx.Repo.Repository.FullName(),
|
||||
"commitID": ctx.Repo.CommitID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,16 @@ func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner
|
||||
setPathsCompareContext(ctx, before, head, headOwner, headName)
|
||||
setImageCompareContext(ctx)
|
||||
setCsvCompareContext(ctx)
|
||||
|
||||
// Sourcegraph code intelligence configuration
|
||||
if setting.Sourcegraph.Enabled {
|
||||
ctx.Data["SourcegraphEnabled"] = true
|
||||
ctx.Data["SourcegraphConfig"] = map[string]any{
|
||||
"enabled": true,
|
||||
"repoFullName": headOwner + "/" + headName,
|
||||
"commitID": head.ID.String(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SourceCommitURL creates a relative URL for a commit in the given repository
|
||||
|
||||
@@ -256,6 +256,16 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) {
|
||||
default:
|
||||
// unable to render anything, show the "view raw" or let frontend handle it
|
||||
}
|
||||
|
||||
// Sourcegraph code intelligence configuration
|
||||
if setting.Sourcegraph.Enabled {
|
||||
ctx.Data["SourcegraphEnabled"] = true
|
||||
ctx.Data["SourcegraphConfig"] = map[string]any{
|
||||
"enabled": true,
|
||||
"repoFullName": ctx.Repo.Repository.FullName(),
|
||||
"commitID": ctx.Repo.CommitID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func prepareFileViewEditorButtons(ctx *context.Context) bool {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sourcegraph
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/cache"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
// Client wraps Sourcegraph GraphQL API
|
||||
type Client struct {
|
||||
baseURL string
|
||||
accessToken string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Range represents a range in source code
|
||||
type Range struct {
|
||||
Start Position `json:"start"`
|
||||
End Position `json:"end"`
|
||||
}
|
||||
|
||||
// Position represents a position in source code
|
||||
type Position struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
}
|
||||
|
||||
// HoverResult represents hover documentation response
|
||||
type HoverResult struct {
|
||||
Contents string `json:"contents"` // Markdown content
|
||||
Range *Range `json:"range,omitempty"`
|
||||
}
|
||||
|
||||
// Location represents a code location
|
||||
type Location struct {
|
||||
Repo string `json:"repo"`
|
||||
Path string `json:"path"`
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
var defaultClient *Client
|
||||
|
||||
// GetClient returns the singleton Sourcegraph client
|
||||
func GetClient() *Client {
|
||||
return defaultClient
|
||||
}
|
||||
|
||||
// NewClient creates a new Sourcegraph client with current settings
|
||||
func NewClient() *Client {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: setting.Sourcegraph.SkipTLSVerify, //nolint:gosec
|
||||
},
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: setting.Sourcegraph.URL,
|
||||
accessToken: setting.Sourcegraph.AccessToken,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(setting.Sourcegraph.Timeout) * time.Second,
|
||||
Transport: transport,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// graphqlRequest performs a GraphQL request to Sourcegraph
|
||||
func (c *Client) graphqlRequest(ctx context.Context, query string, variables map[string]any) (json.RawMessage, error) {
|
||||
body := map[string]any{
|
||||
"query": query,
|
||||
"variables": variables,
|
||||
}
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/.api/graphql", bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "token "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
return nil, fmt.Errorf("graphql error: %s", result.Errors[0].Message)
|
||||
}
|
||||
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// cacheKey generates a cache key for code intelligence queries
|
||||
func cacheKey(queryType, repo, rev, path string, line, char int) string {
|
||||
return fmt.Sprintf("sg:%s:%s:%s:%s:%d:%d", queryType, repo, rev, path, line, char)
|
||||
}
|
||||
|
||||
// Hover queries documentation at a position
|
||||
func (c *Client) Hover(ctx context.Context, repo, rev, path string, line, char int) (*HoverResult, error) {
|
||||
key := cacheKey("hover", repo, rev, path, line, char)
|
||||
|
||||
// Try cache first
|
||||
if cached, hit := cache.GetCache().Get(key); hit {
|
||||
var result HoverResult
|
||||
if err := json.Unmarshal([]byte(cached), &result); err == nil {
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
|
||||
data, err := c.graphqlRequest(ctx, hoverQuery, map[string]any{
|
||||
"repo": repo,
|
||||
"rev": rev,
|
||||
"path": path,
|
||||
"line": line,
|
||||
"char": char,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := parseHoverResponse(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if result != nil {
|
||||
if resultBytes, err := json.Marshal(result); err == nil {
|
||||
_ = cache.GetCache().Put(key, string(resultBytes), int64(setting.Sourcegraph.CacheTTL))
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Definition queries for symbol definition
|
||||
func (c *Client) Definition(ctx context.Context, repo, rev, path string, line, char int) ([]Location, error) {
|
||||
key := cacheKey("def", repo, rev, path, line, char)
|
||||
|
||||
// Try cache first
|
||||
if cached, hit := cache.GetCache().Get(key); hit {
|
||||
var result []Location
|
||||
if err := json.Unmarshal([]byte(cached), &result); err == nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
data, err := c.graphqlRequest(ctx, definitionQuery, map[string]any{
|
||||
"repo": repo,
|
||||
"rev": rev,
|
||||
"path": path,
|
||||
"line": line,
|
||||
"char": char,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := parseLocationsResponse(data, "definitions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if resultBytes, err := json.Marshal(result); err == nil {
|
||||
_ = cache.GetCache().Put(key, string(resultBytes), int64(setting.Sourcegraph.CacheTTL))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// References queries for all references to a symbol
|
||||
func (c *Client) References(ctx context.Context, repo, rev, path string, line, char int) ([]Location, error) {
|
||||
key := cacheKey("refs", repo, rev, path, line, char)
|
||||
|
||||
// Try cache first
|
||||
if cached, hit := cache.GetCache().Get(key); hit {
|
||||
var result []Location
|
||||
if err := json.Unmarshal([]byte(cached), &result); err == nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
data, err := c.graphqlRequest(ctx, referencesQuery, map[string]any{
|
||||
"repo": repo,
|
||||
"rev": rev,
|
||||
"path": path,
|
||||
"line": line,
|
||||
"char": char,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := parseLocationsResponse(data, "references")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if resultBytes, err := json.Marshal(result); err == nil {
|
||||
_ = cache.GetCache().Put(key, string(resultBytes), int64(setting.Sourcegraph.CacheTTL))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SyncRepository notifies Sourcegraph to re-sync a repository
|
||||
func (c *Client) SyncRepository(ctx context.Context, repoName string) error {
|
||||
// Use the repository sync mutation
|
||||
_, err := c.graphqlRequest(ctx, syncRepoMutation, map[string]any{
|
||||
"repo": repoName,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("Sourcegraph sync failed for %s: %v", repoName, err)
|
||||
return err
|
||||
}
|
||||
log.Debug("Sourcegraph sync triggered for %s", repoName)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sourcegraph
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GraphQL queries for Sourcegraph API
|
||||
|
||||
const hoverQuery = `
|
||||
query Hover($repo: String!, $rev: String!, $path: String!, $line: Int!, $char: Int!) {
|
||||
repository(name: $repo) {
|
||||
commit(rev: $rev) {
|
||||
blob(path: $path) {
|
||||
lsif {
|
||||
hover(line: $line, character: $char) {
|
||||
markdown {
|
||||
text
|
||||
}
|
||||
range {
|
||||
start { line character }
|
||||
end { line character }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const definitionQuery = `
|
||||
query Definition($repo: String!, $rev: String!, $path: String!, $line: Int!, $char: Int!) {
|
||||
repository(name: $repo) {
|
||||
commit(rev: $rev) {
|
||||
blob(path: $path) {
|
||||
lsif {
|
||||
definitions(line: $line, character: $char) {
|
||||
nodes {
|
||||
resource {
|
||||
path
|
||||
repository {
|
||||
name
|
||||
}
|
||||
commit {
|
||||
oid
|
||||
}
|
||||
}
|
||||
range {
|
||||
start { line character }
|
||||
end { line character }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const referencesQuery = `
|
||||
query References($repo: String!, $rev: String!, $path: String!, $line: Int!, $char: Int!) {
|
||||
repository(name: $repo) {
|
||||
commit(rev: $rev) {
|
||||
blob(path: $path) {
|
||||
lsif {
|
||||
references(line: $line, character: $char, first: 100) {
|
||||
nodes {
|
||||
resource {
|
||||
path
|
||||
repository {
|
||||
name
|
||||
}
|
||||
commit {
|
||||
oid
|
||||
}
|
||||
}
|
||||
range {
|
||||
start { line character }
|
||||
end { line character }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const syncRepoMutation = `
|
||||
mutation SyncRepo($repo: String!) {
|
||||
scheduleRepositoryPermissionsSync(repository: $repo) {
|
||||
alwaysNil
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Response parsing structures
|
||||
|
||||
type hoverResponse struct {
|
||||
Repository *struct {
|
||||
Commit *struct {
|
||||
Blob *struct {
|
||||
LSIF *struct {
|
||||
Hover *struct {
|
||||
Markdown *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"markdown"`
|
||||
Range *struct {
|
||||
Start struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
} `json:"start"`
|
||||
End struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
} `json:"end"`
|
||||
} `json:"range"`
|
||||
} `json:"hover"`
|
||||
} `json:"lsif"`
|
||||
} `json:"blob"`
|
||||
} `json:"commit"`
|
||||
} `json:"repository"`
|
||||
}
|
||||
|
||||
type locationsResponse struct {
|
||||
Repository *struct {
|
||||
Commit *struct {
|
||||
Blob *struct {
|
||||
LSIF *struct {
|
||||
Definitions *locationNodes `json:"definitions,omitempty"`
|
||||
References *locationNodes `json:"references,omitempty"`
|
||||
} `json:"lsif"`
|
||||
} `json:"blob"`
|
||||
} `json:"commit"`
|
||||
} `json:"repository"`
|
||||
}
|
||||
|
||||
type locationNodes struct {
|
||||
Nodes []struct {
|
||||
Resource struct {
|
||||
Path string `json:"path"`
|
||||
Repository struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"repository"`
|
||||
Commit struct {
|
||||
OID string `json:"oid"`
|
||||
} `json:"commit"`
|
||||
} `json:"resource"`
|
||||
Range struct {
|
||||
Start struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
} `json:"start"`
|
||||
End struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
} `json:"end"`
|
||||
} `json:"range"`
|
||||
} `json:"nodes"`
|
||||
}
|
||||
|
||||
func parseHoverResponse(data json.RawMessage) (*HoverResult, error) {
|
||||
var resp hoverResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse hover response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Repository == nil || resp.Repository.Commit == nil ||
|
||||
resp.Repository.Commit.Blob == nil || resp.Repository.Commit.Blob.LSIF == nil ||
|
||||
resp.Repository.Commit.Blob.LSIF.Hover == nil {
|
||||
return nil, nil // No hover info available
|
||||
}
|
||||
|
||||
hover := resp.Repository.Commit.Blob.LSIF.Hover
|
||||
result := &HoverResult{}
|
||||
|
||||
if hover.Markdown != nil {
|
||||
result.Contents = hover.Markdown.Text
|
||||
}
|
||||
|
||||
if hover.Range != nil {
|
||||
result.Range = &Range{
|
||||
Start: Position{
|
||||
Line: hover.Range.Start.Line,
|
||||
Character: hover.Range.Start.Character,
|
||||
},
|
||||
End: Position{
|
||||
Line: hover.Range.End.Line,
|
||||
Character: hover.Range.End.Character,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseLocationsResponse(data json.RawMessage, field string) ([]Location, error) {
|
||||
var resp locationsResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse locations response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Repository == nil || resp.Repository.Commit == nil ||
|
||||
resp.Repository.Commit.Blob == nil || resp.Repository.Commit.Blob.LSIF == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var nodes *locationNodes
|
||||
switch field {
|
||||
case "definitions":
|
||||
nodes = resp.Repository.Commit.Blob.LSIF.Definitions
|
||||
case "references":
|
||||
nodes = resp.Repository.Commit.Blob.LSIF.References
|
||||
}
|
||||
|
||||
if nodes == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
locations := make([]Location, 0, len(nodes.Nodes))
|
||||
for _, node := range nodes.Nodes {
|
||||
locations = append(locations, Location{
|
||||
Repo: node.Resource.Repository.Name,
|
||||
Path: node.Resource.Path,
|
||||
Line: node.Range.Start.Line,
|
||||
Character: node.Range.Start.Character,
|
||||
})
|
||||
}
|
||||
|
||||
return locations, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sourcegraph
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
)
|
||||
|
||||
// Init initializes the Sourcegraph service
|
||||
func Init() error {
|
||||
if !setting.Sourcegraph.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultClient = NewClient()
|
||||
notify_service.RegisterNotifier(NewNotifier())
|
||||
|
||||
log.Info("Sourcegraph integration enabled: %s", setting.Sourcegraph.URL)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsEnabled returns whether Sourcegraph integration is enabled
|
||||
func IsEnabled() bool {
|
||||
return setting.Sourcegraph.Enabled && defaultClient != nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sourcegraph
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
)
|
||||
|
||||
type sourcegraphNotifier struct {
|
||||
notify_service.NullNotifier
|
||||
}
|
||||
|
||||
var _ notify_service.Notifier = &sourcegraphNotifier{}
|
||||
|
||||
// NewNotifier creates a new Sourcegraph notifier
|
||||
func NewNotifier() notify_service.Notifier {
|
||||
return &sourcegraphNotifier{}
|
||||
}
|
||||
|
||||
func (n *sourcegraphNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
|
||||
if !setting.Sourcegraph.Enabled || !setting.Sourcegraph.SyncOnPush {
|
||||
return
|
||||
}
|
||||
|
||||
client := GetClient()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger Sourcegraph to re-sync the repository
|
||||
if err := client.SyncRepository(ctx, repo.FullName()); err != nil {
|
||||
log.Warn("Sourcegraph sync failed for %s: %v", repo.FullName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *sourcegraphNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
|
||||
// Also sync on mirror push
|
||||
n.PushCommits(ctx, pusher, repo, opts, commits)
|
||||
}
|
||||
@@ -29,7 +29,8 @@
|
||||
</div>
|
||||
</h4>
|
||||
<div class="ui bottom attached table unstackable segment">
|
||||
<div class="file-view code-view unicode-escaped">
|
||||
<div class="file-view code-view unicode-escaped"
|
||||
{{if .SourcegraphEnabled}}data-global-init="initSourcegraph" data-sourcegraph="{{JsonUtils.EncodeToString .SourcegraphConfig}}" data-sg-path="{{.FileTreePath}}"{{end}}>
|
||||
{{if .IsFileTooLarge}}
|
||||
{{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}}
|
||||
{{else if not .FileSize}}
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
{{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}}
|
||||
{{$isExpandable := or (gt $file.Addition 0) (gt $file.Deletion 0) $file.IsBin}}
|
||||
{{$isReviewFile := and $.IsSigned $.PageIsPullFiles (not $.Repository.IsArchived) $.IsShowingAllCommits}}
|
||||
<div class="diff-file-box file-content {{TabSizeClass $.Editorconfig $file.Name}} tw-mt-0" id="diff-{{$file.NameHash}}" data-old-filename="{{$file.OldName}}" data-new-filename="{{$file.Name}}" {{if or ($file.ShouldBeHidden) (not $isExpandable)}}data-folded="true"{{end}}>
|
||||
<div class="diff-file-box file-content {{TabSizeClass $.Editorconfig $file.Name}} tw-mt-0" id="diff-{{$file.NameHash}}" data-old-filename="{{$file.OldName}}" data-new-filename="{{$file.Name}}" {{if or ($file.ShouldBeHidden) (not $isExpandable)}}data-folded="true"{{end}}
|
||||
{{if $.SourcegraphEnabled}}data-global-init="initSourcegraph" data-sourcegraph="{{JsonUtils.EncodeToString $.SourcegraphConfig}}" data-sg-path="{{$file.Name}}"{{end}}>
|
||||
<div class="diff-file-header sticky-2nd-row ui top attached header">
|
||||
<div class="diff-file-name tw-flex tw-flex-1 tw-items-center tw-gap-1 tw-flex-wrap">
|
||||
<div class="flex-text-block">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<div {{if .ReadmeInList}}id="readme"{{end}} class="{{TabSizeClass .Editorconfig .FileTreePath}} non-diff-file-content"
|
||||
data-global-init="initRepoFileView" data-raw-file-link="{{.RawFileLink}}">
|
||||
data-global-init="initRepoFileView{{if .SourcegraphEnabled}} initSourcegraph{{end}}" data-raw-file-link="{{.RawFileLink}}"
|
||||
{{if .SourcegraphEnabled}}data-sourcegraph="{{JsonUtils.EncodeToString .SourcegraphConfig}}" data-sg-path="{{.TreePath}}"{{end}}>
|
||||
|
||||
{{- if .FileError}}
|
||||
<div class="ui error message">
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/* Sourcegraph code intelligence styles */
|
||||
|
||||
.sourcegraph-hover {
|
||||
max-width: 600px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sourcegraph-hover .sg-content {
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sourcegraph-hover .sg-content pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background: var(--color-code-bg);
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: var(--fonts-monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sourcegraph-hover .sg-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
/* References panel */
|
||||
.sg-refs-panel {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 100px;
|
||||
width: 400px;
|
||||
max-height: 60vh;
|
||||
background: var(--color-body);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sg-refs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
background: var(--color-box-header);
|
||||
}
|
||||
|
||||
.sg-refs-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sg-refs-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-light);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sg-refs-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sg-refs-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sg-refs-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-family: var(--fonts-monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sg-refs-item:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.sg-refs-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sg-refs-path {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sg-refs-line {
|
||||
color: var(--color-text-light);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Highlight hoverable tokens on hover */
|
||||
.code-view .code-inner span:hover {
|
||||
background: var(--color-hover);
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
@import "./features/cropper.css";
|
||||
@import "./features/console.css";
|
||||
@import "./features/captcha.css";
|
||||
@import "./features/sourcegraph.css";
|
||||
|
||||
@import "./markup/content.css";
|
||||
@import "./markup/codecopy.css";
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import type {Instance as TippyInstance} from 'tippy.js';
|
||||
|
||||
interface SourcegraphConfig {
|
||||
enabled: boolean;
|
||||
repoFullName: string;
|
||||
commitID: string;
|
||||
}
|
||||
|
||||
interface HoverResult {
|
||||
contents?: string;
|
||||
range?: {
|
||||
start: {line: number; character: number};
|
||||
end: {line: number; character: number};
|
||||
};
|
||||
}
|
||||
|
||||
interface Location {
|
||||
repo: string;
|
||||
path: string;
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
|
||||
let activeTippy: TippyInstance | null = null;
|
||||
let hoverTimeout: number | null = null;
|
||||
let refsPanel: HTMLElement | null = null;
|
||||
|
||||
async function fetchHover(config: SourcegraphConfig, path: string, line: number, char: number): Promise<HoverResult | null> {
|
||||
const params = new URLSearchParams({
|
||||
path,
|
||||
line: String(line),
|
||||
character: String(char),
|
||||
ref: config.commitID,
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await GET(`/api/v1/repos/${config.repoFullName}/sourcegraph/hover?${params}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data.contents ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDefinition(config: SourcegraphConfig, path: string, line: number, char: number): Promise<Location[]> {
|
||||
const params = new URLSearchParams({
|
||||
path,
|
||||
line: String(line),
|
||||
character: String(char),
|
||||
ref: config.commitID,
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await GET(`/api/v1/repos/${config.repoFullName}/sourcegraph/definition?${params}`);
|
||||
if (!resp.ok) return [];
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchReferences(config: SourcegraphConfig, path: string, line: number, char: number): Promise<Location[]> {
|
||||
const params = new URLSearchParams({
|
||||
path,
|
||||
line: String(line),
|
||||
character: String(char),
|
||||
ref: config.commitID,
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await GET(`/api/v1/repos/${config.repoFullName}/sourcegraph/references?${params}`);
|
||||
if (!resp.ok) return [];
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function hideActiveTippy(): void {
|
||||
if (activeTippy) {
|
||||
activeTippy.destroy();
|
||||
activeTippy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function hideRefsPanel(): void {
|
||||
if (refsPanel) {
|
||||
refsPanel.remove();
|
||||
refsPanel = null;
|
||||
}
|
||||
}
|
||||
|
||||
function createHoverContent(
|
||||
contents: string,
|
||||
config: SourcegraphConfig,
|
||||
path: string,
|
||||
line: number,
|
||||
char: number,
|
||||
): HTMLElement {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sourcegraph-hover';
|
||||
|
||||
// Render markdown content as pre-formatted text (simple approach)
|
||||
// In production, you might want to use a proper markdown renderer
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'sg-content';
|
||||
contentDiv.innerHTML = `<pre>${escapeHtml(contents)}</pre>`;
|
||||
el.appendChild(contentDiv);
|
||||
|
||||
// Action buttons
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'sg-actions';
|
||||
|
||||
const goToDefBtn = document.createElement('button');
|
||||
goToDefBtn.className = 'ui mini basic button';
|
||||
goToDefBtn.textContent = 'Go to definition';
|
||||
goToDefBtn.addEventListener('click', async () => {
|
||||
hideActiveTippy();
|
||||
const locations = await fetchDefinition(config, path, line, char);
|
||||
if (locations.length === 1) {
|
||||
navigateToLocation(locations[0]);
|
||||
} else if (locations.length > 1) {
|
||||
showLocationsPanel('Definitions', locations);
|
||||
}
|
||||
});
|
||||
actionsDiv.appendChild(goToDefBtn);
|
||||
|
||||
const findRefsBtn = document.createElement('button');
|
||||
findRefsBtn.className = 'ui mini basic button';
|
||||
findRefsBtn.textContent = 'Find references';
|
||||
findRefsBtn.addEventListener('click', async () => {
|
||||
hideActiveTippy();
|
||||
const locations = await fetchReferences(config, path, line, char);
|
||||
if (locations.length > 0) {
|
||||
showLocationsPanel('References', locations);
|
||||
}
|
||||
});
|
||||
actionsDiv.appendChild(findRefsBtn);
|
||||
|
||||
el.appendChild(actionsDiv);
|
||||
return el;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function navigateToLocation(loc: Location): void {
|
||||
// Build URL to the location
|
||||
// If it's in the same repo, navigate to file
|
||||
// If it's in another repo, we need to construct the full URL
|
||||
const currentPath = window.location.pathname;
|
||||
const repoMatch = currentPath.match(/^\/([^/]+\/[^/]+)/);
|
||||
const currentRepo = repoMatch ? repoMatch[1] : '';
|
||||
|
||||
let url: string;
|
||||
if (loc.repo === currentRepo || !loc.repo) {
|
||||
// Same repo - construct relative URL
|
||||
const basePath = currentPath.replace(/\/src\/.*$/, '');
|
||||
url = `${basePath}/src/branch/main/${loc.path}#L${loc.line + 1}`;
|
||||
} else {
|
||||
// Different repo
|
||||
url = `/${loc.repo}/src/branch/main/${loc.path}#L${loc.line + 1}`;
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function showLocationsPanel(title: string, locations: Location[]): void {
|
||||
hideRefsPanel();
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'sg-refs-panel';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'sg-refs-header';
|
||||
header.innerHTML = `
|
||||
<span class="sg-refs-title">${escapeHtml(title)} (${locations.length})</span>
|
||||
<button class="sg-refs-close">×</button>
|
||||
`;
|
||||
panel.appendChild(header);
|
||||
|
||||
header.querySelector('.sg-refs-close')?.addEventListener('click', hideRefsPanel);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'sg-refs-list';
|
||||
|
||||
for (const loc of locations) {
|
||||
const item = document.createElement('a');
|
||||
item.className = 'sg-refs-item';
|
||||
item.href = '#';
|
||||
item.innerHTML = `
|
||||
<span class="sg-refs-path">${escapeHtml(loc.path)}</span>
|
||||
<span class="sg-refs-line">:${loc.line + 1}</span>
|
||||
`;
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
navigateToLocation(loc);
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
panel.appendChild(list);
|
||||
document.body.appendChild(panel);
|
||||
refsPanel = panel;
|
||||
}
|
||||
|
||||
function getTokenPosition(container: HTMLElement, token: Element, path: string): {path: string; line: number; char: number} | null {
|
||||
const row = token.closest('tr');
|
||||
if (!row) return null;
|
||||
|
||||
// Support both data-line-number (file view, blame) and data-line-num (diff view)
|
||||
let lineEl = row.querySelector('[data-line-number]') as HTMLElement | null;
|
||||
let line: number;
|
||||
|
||||
if (lineEl) {
|
||||
line = parseInt(lineEl.dataset.lineNumber || '0', 10);
|
||||
} else {
|
||||
// For diff views, prefer the "new" line number (right side) for code intelligence
|
||||
lineEl = row.querySelector('.lines-num-new[data-line-num]') as HTMLElement | null;
|
||||
if (!lineEl) {
|
||||
lineEl = row.querySelector('[data-line-num]') as HTMLElement | null;
|
||||
}
|
||||
if (!lineEl) return null;
|
||||
line = parseInt(lineEl.dataset.lineNum || '0', 10);
|
||||
}
|
||||
|
||||
if (isNaN(line) || line <= 0) return null;
|
||||
|
||||
// Calculate character offset within the line
|
||||
const codeCell = row.querySelector('.lines-code .code-inner');
|
||||
if (!codeCell) return null;
|
||||
|
||||
let char = 0;
|
||||
const walker = document.createTreeWalker(codeCell, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
if (token.contains(node)) {
|
||||
// Found the text node containing or preceding our token
|
||||
break;
|
||||
}
|
||||
char += node.textContent?.length || 0;
|
||||
}
|
||||
|
||||
// Sourcegraph uses 0-indexed lines
|
||||
return {path, line: line - 1, char};
|
||||
}
|
||||
|
||||
function isHoverableToken(el: Element): boolean {
|
||||
// Check if it's a span inside code-inner (syntax highlighted token)
|
||||
if (el.tagName !== 'SPAN') return false;
|
||||
const codeInner = el.closest('.code-inner');
|
||||
return codeInner !== null;
|
||||
}
|
||||
|
||||
export function initSourcegraph(): void {
|
||||
registerGlobalInitFunc('initSourcegraph', (container: HTMLElement) => {
|
||||
const configStr = container.dataset.sourcegraph;
|
||||
if (!configStr) return;
|
||||
|
||||
let config: SourcegraphConfig;
|
||||
try {
|
||||
config = JSON.parse(configStr);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.enabled) return;
|
||||
|
||||
const path = container.dataset.sgPath || '';
|
||||
if (!path) return;
|
||||
|
||||
// Set up hover listener with debouncing
|
||||
container.addEventListener('mouseover', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!isHoverableToken(target)) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (hoverTimeout) {
|
||||
window.clearTimeout(hoverTimeout);
|
||||
}
|
||||
|
||||
hoverTimeout = window.setTimeout(async () => {
|
||||
const pos = getTokenPosition(container, target, path);
|
||||
if (!pos) return;
|
||||
|
||||
const hover = await fetchHover(config, pos.path, pos.line, pos.char);
|
||||
if (!hover?.contents) return;
|
||||
|
||||
// Hide any existing tippy
|
||||
hideActiveTippy();
|
||||
|
||||
// Create and show new tippy
|
||||
const content = createHoverContent(hover.contents, config, pos.path, pos.line, pos.char);
|
||||
activeTippy = createTippy(target, {
|
||||
content,
|
||||
theme: 'default',
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'top',
|
||||
maxWidth: 600,
|
||||
onHide: () => {
|
||||
activeTippy = null;
|
||||
},
|
||||
});
|
||||
activeTippy.show();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
container.addEventListener('mouseout', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!isHoverableToken(target)) return;
|
||||
|
||||
if (hoverTimeout) {
|
||||
window.clearTimeout(hoverTimeout);
|
||||
hoverTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Close refs panel when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (refsPanel && !refsPanel.contains(e.target as Node)) {
|
||||
hideRefsPanel();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {initStopwatch} from './features/stopwatch.ts';
|
||||
import {initRepoFileSearch} from './features/repo-findfile.ts';
|
||||
import {initMarkupContent} from './markup/content.ts';
|
||||
import {initRepoFileView} from './features/file-view.ts';
|
||||
import {initSourcegraph} from './features/sourcegraph.ts';
|
||||
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
|
||||
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
|
||||
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
|
||||
@@ -158,6 +159,7 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initOAuth2SettingsDisableCheckbox,
|
||||
|
||||
initRepoFileView,
|
||||
initSourcegraph,
|
||||
]);
|
||||
|
||||
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
|
||||
|
||||
Reference in New Issue
Block a user