diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ed83fe6ef..4c487267d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,9 @@ on: jobs: build: + permissions: + contents: read + strategy: matrix: os: [macos-14] @@ -19,6 +22,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: @@ -28,5 +33,12 @@ jobs: - run: npm install - run: npm run eslint - run: npm run build - - uses: microsoft/playwright-github-action@v1 - - run: npm run test:ci + env: + GITHUB_API_PREFIX: http://localhost:8080/api/github + - name: Install snapshot test dependencies + working-directory: tests + run: npm ci && npx playwright install --with-deps + - name: Run snapshot tests + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx start-server-and-test watch:dev-server http://localhost:8080 "env -u GITHUB_TOKEN npm --prefix tests test" diff --git a/.github/workflows/test-wtih-vscode-build.yml b/.github/workflows/test-wtih-vscode-build.yml index d04876a72..2d35e02a9 100644 --- a/.github/workflows/test-wtih-vscode-build.yml +++ b/.github/workflows/test-wtih-vscode-build.yml @@ -37,5 +37,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm run build - run: npm run link && npm run build - - uses: microsoft/playwright-github-action@v1 - - run: npm run test:ci + env: + GITHUB_API_PREFIX: http://localhost:8080/api/github + - name: Install snapshot test dependencies + working-directory: tests + run: npm ci && npx playwright install --with-deps + - name: Run snapshot tests + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx start-server-and-test watch:dev-server http://localhost:8080 "env -u GITHUB_TOKEN npm --prefix tests test" diff --git a/.gitignore b/.gitignore index 7f61e2ee3..14ab9cbcc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist out node_modules .worktrees/ +tests/__tests__/__image_snapshots__/__diff_output__/ diff --git a/README.md b/README.md index 1f4960174..d81ce7fe3 100644 --- a/README.md +++ b/README.md @@ -1,198 +1,67 @@ -![GitHub1s](https://raw.githubusercontent.com/conwnet/github1s/master/resources/images/logo.svg) +![GitHub1s](resources/images/logo.svg) -# github1s +# GitHub1s -One second to read GitHub code with VS Code. +GitHub1s is a browser-based code explorer built on VS Code. Browse files, search code, and review changes without cloning a repository. Connect your preferred model to the built-in AI chat for help understanding the code. -## Usage +**[๐ŸŒ Try it now](https://github1s.com/conwnet/github1s)** ยท [๐Ÿ“– Usage guide](docs/usage.md) ยท [๐Ÿ”ฎ AI setup](docs/ai.md#configure-a-model) -Just add `1s` after `github` and press `Enter` in the browser address bar for any repository you want to read. +## ๐Ÿš€ Quick start -For example, try it on the VS Code repo: +Add **`1s` after `github`** in a repository URL: -[https://github1s.com/microsoft/vscode](https://github1s.com/microsoft/vscode) - -![VS Code - GitHub1s](https://raw.githubusercontent.com/conwnet/github1s/master/resources/images/vs-code-github1s.png) - -You can also use [https://gitlab1s.com](https://gitlab1s.com) or [https://npmjs1s.com](https://npmjs1s.com) in the same way. - -For browser extensions, see [Third-party Related Projects](https://github.com/conwnet/github1s#third-party-related-projects). - -Or save the following code snippet as a bookmarklet, you can use it to quickly switch between github.com and github1s.com (GitHub markdown doesn't allow js links, so just copy it into a bookmark). - -``` -javascript: window.location.href = window.location.href.replace(/github(1s)?.com/, function(match, p1) { return p1 ? 'github.com' : 'github1s.com' }) +```text +https://github.com/microsoft/vscode + โ†“ +https://github1s.com/microsoft/vscode ``` -### Develop in the cloud - -To edit files, run Docker containers, create pull requests and more, click the "Develop your project on [Gitpod](https://www.gitpod.io)" button in the status bar. You can also open the Command Palette (default shortcut `Ctrl+Shift+P`) and choose `GitHub1s: Edit files in Gitpod`. - -![Gitpod Status Bar](https://raw.githubusercontent.com/conwnet/github1s/master/resources/images/gitpod-statusbar.png) - -## Documentation - -- [How it works](https://github.com/conwnet/github1s/blob/master/docs/guide.md) -- [Roadmap](https://github.com/conwnet/github1s/projects/1) - -## Enabling Private Repositories - -If you want to view non-public repositories, you need to add an OAuth token. The token is stored only in your browser, and only send to GitHub when fetching your repository's files. Click on the icon near the bottom of the left-hand row of icons, and the dialog box will prompt you for it, and even take you to your GitHub settings page to generate one, if needed. - - - -## Screenshots - -![VS Code - GitHub1s](https://raw.githubusercontent.com/conwnet/github1s/master/resources/images/GitHub1sDemo1.gif) - -![VS Code - GitHub1s](https://raw.githubusercontent.com/conwnet/github1s/master/resources/images/demo.png) - -## Development - -### Cloud-based development - -You can start an online development environment with [Gitpod](https://www.gitpod.io) by clicking the following button: - -[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/conwnet/github1s) - -### Local development - -```bash -git clone git@github.com:conwnet/github1s.git -cd github1s -npm install -npm run watch -# The cli will automatically open http://localhost:8080 once the build is completed. -# You can visit http://localhost:8080/conwnet/github1s if it doesn't. -``` - -#### Local development with full VS Code build - -You need [these prerequisites (the same ones as for VS Code)](https://github.com/microsoft/vscode/wiki/How-to-Contribute#prerequisites) for development with full VS Code build. -Please make sure you could build VS Code locally before the watch mode. - -To verify the build: - -```bash -cd github1s -npm run build:vscode -``` - -After the initial successful build, you could use the watch mode: - -```bash -cd github1s -npm install -npm run watch-with-vscode -# The cli will automatically open http://localhost:8080 once the build is completed. -# You can visit http://localhost:8080/conwnet/github1s if it doesn't. -``` - -### ... or ... VS Code + Docker Development - -You can use the VS Code plugin [Remote-Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) `Dev Container` to use a Docker container as a development environment. - -1. Install the Remote-Containers plugin in VS Code & Docker -2. Open the Command Palette (default shortcut `Ctrl+Shift+P`) and choose `Remote-Containers: Clone Repository in Container Volume...` -3. Enter the repo, in this case `https://github.com/conwnet/github1s.git` or your forked repo -4. Pick either, `Create a unique volume` or `Create a new volume` - - - Now VS Code will create the docker container and connect to the new container so you can use this as a fully setup environment! - -5. Open a new VS Code Terminal, then you can run the `npm install` commands listed above. - -```bash -npm install -npm run watch -# The cli will automatically open http://localhost:8080 once the build is completed. -# You can visit http://localhost:8080/conwnet/github1s if it doesn't. -``` - -### Format all codes - -```bash -npm run format -``` - -It uses `prettier` to format all possible codes. - -## Build - -```bash -npm install -npm run build -``` - -## Feedback - -- If something is not working, [create an issue](https://github.com/conwnet/github1s/issues/new) - -## Sponsors - -The continued development and maintenance of GitHub1s is made possible by these generous sponsors: +Public repositories can be browsed **without signing in**, subject to the upstream services' access and rate limits. - - -
- -
+![A repository open in GitHub1s, with the file explorer and README preview](resources/images/demo.png) -## Partners +You can also browse GitLab repositories at [gitlab1s.com](https://gitlab1s.com) and published npm packages at [npmjs1s.com](https://npmjs1s.com). See the [usage guide](docs/usage.md) for URL formats, authentication, and navigation. -We are partnered with [OSS Insight](https://ossinsight.io/?utm_source=github1s&utm_medium=github&utm_campaign=ghtrending) to get the Trending Repositories & some more Interesting Analytics. [OSS Insight](https://ossinsight.io/?utm_source=github1s&utm_medium=github&utm_campaign=ghtrending) provides deep insights into GitHub repos, developers, and curated repo lists from billions of GitHub events. Itโ€™s built with [TiDB Cloud](https://www.pingcap.com/tidb-cloud/?utm_source=github1s&utm_medium=github&utm_campaign=ghtrending). +## โœจ Features - - -
- -
+- **Explore a project.** Browse directories, find files, and search code in a familiar VS Code interface. +- **Trace a change.** Switch branches or tags, inspect commits and file history, and review pull or merge request diffs on GitHub and GitLab. +- **Understand complex code.** Ask GitHub1s AI about a file or selection, then follow up on implementation details. +- **Access private repositories.** Connect your GitHub or GitLab account, or supply an access token with the necessary permissions. -## Maintainers! :blush: +Remote repositories are **read-only**. Search and code navigation depend on the repository platform and available upstream services; see [capabilities and limits](docs/usage.md#capabilities-and-limits). - - - - - - -

netcon

๐Ÿ’ป ๐Ÿ–‹

xcv58

๐Ÿ’ป ๐Ÿ–‹

Siddhant Khare

๐Ÿ’ป ๐Ÿ–‹
+## ๐Ÿค– GitHub1s AI -## Stargazers over time +Get a repository overview, explain a file or selection, and ask follow-up questions. The assistant can look up relevant code as you chat. -[![Stargazers over time](https://api.star-history.com/svg?repos=conwnet/github1s&type=Date)](https://star-history.com/#conwnet/github1s&Date) +Click **Toggle Secondary Side Bar** in the layout controls at the top of GitHub1s to open the AI panel. -
-Third-party Related Projects -
+Use a **model endpoint that accepts browser requests**. Messages and code context are sent to the selected endpoint. See the [AI guide](docs/ai.md) for configuration and data handling. -### Chrome Extensions +## ๐Ÿ“š Documentation -- [Repositree](https://chrome.google.com/webstore/detail/repositree/lafjldoccjnjlcmdhmniholdpjkbgajo) ([chouglesaud/repositree](https://github.com/chouglesaud/repositree)) -- [github-code-viewer](https://chrome.google.com/webstore/detail/github-code-viewer/ecddapgifccgblebfibdgkagfbdagjfn) ([febaoshan/edge-extensions-github-code-viewer](https://github.com/febaoshan/edge-extensions-github-code-viewer)) -- Github1s Extension ([Darkempire78/GitHub1s-Extension](https://github.com/Darkempire78/GitHub1s-Extension)) -- [Github Web IDE](https://chrome.google.com/webstore/detail/adjiklnjodbiaioggfpbpkhbfcnhgkfe) ([zvizvi/Github-Web-IDE](https://github.com/zvizvi/Github-Web-IDE)) -- [shortcut to github1s](https://chrome.google.com/webstore/detail/shortcut-to-github1s/gfcdbodapcbfckbfpmgeldfkkgjknceo) ([katsuhisa91/github1s-shortcut](https://github.com/katsuhisa91/github1s-shortcut)) -- [Github1s Shortut - Open source](https://github.com/Fauzdar1/Github1s) -- [โšก๏ธ 1s to GitHub1s!](https://github.com/holazz/webext-github1s) -- [github1s Google Chrome Extensions](https://github.com/Lonely-Mr-zhang/github_1s_vscode) +| Guide | What you will find | +| ------------------------------------ | ------------------------------------------------------- | +| [Using GitHub1s](docs/usage.md) | Navigation, authentication, search, and troubleshooting | +| [GitHub1s AI](docs/ai.md) | Model setup, context, tools, and data handling | +| [Development](docs/development.md) | Local setup, builds, and checks | +| [Architecture](docs/architecture.md) | Components, data flow, and source layout | +| [Deployment](docs/deployment.md) | Hosting, OAuth, and service configuration | +| [Community](docs/community.md) | Third-party extensions and star history | -### Firefox Extensions +## ๐Ÿค Contributing -- [Repositree](https://addons.mozilla.org/en-US/firefox/addon/repositree/) ([chouglesaud/repositree](https://github.com/chouglesaud/repositree)) -- [Github1s Extension](https://addons.mozilla.org/firefox/addon/github1s-extension) ([Darkempire78/GitHub1s-Extension](https://github.com/Darkempire78/GitHub1s-Extension)) -- [Github1s](https://addons.mozilla.org/firefox/addon/github1s/) ([mcherifi/github1s-firefox-addon](https://github.com/mcherifi/github1s-firefox-addon)) -- [Github Web IDE](https://addons.mozilla.org/firefox/addon/github-web-ide/) ([zvizvi/Github-Web-IDE](https://github.com/zvizvi/Github-Web-IDE)) +See the [development guide](docs/development.md) to run GitHub1s locally. Report bugs and suggest improvements through [GitHub Issues](https://github.com/conwnet/github1s/issues). -### Microsoft Edge Extensions +## ๐Ÿ‘ฅ Maintainers -- [github-code-viewer](https://microsoftedge.microsoft.com/addons/detail/githubcodeviewer/jaaaapanahkknbgdbglnlchbjfhhjlpi) ([febaoshan/edge-extensions-github-code-viewer](https://github.com/febaoshan/edge-extensions-github-code-viewer)) -- [Github Web IDE](https://microsoftedge.microsoft.com/addons/detail/akjbkjciknacicbnkfjbnlaeednpadcf) ([zvizvi/Github-Web-IDE](https://github.com/zvizvi/Github-Web-IDE)) +[conwnet](https://github.com/conwnet) ยท [xcv58](https://github.com/xcv58) ยท [Siddhant Khare](https://github.com/Siddhant-K-code) -### Safari Extension +## ๐Ÿ’– Thanks -- [GitHub1s-For-Safari-Extension](https://apps.apple.com/us/app/readcodeonline/id1569026520?mt=12) ([code4you2021/GitHub1s-For-Safari-Extension](https://github.com/code4you2021/GitHub1s-For-Safari-Extension)) +Thanks to everyone who has contributed to GitHub1s, and to [Sourcegraph](https://sourcegraph.com/), [searchcode](https://searchcode.com/), and [OSS Insight](https://ossinsight.io/) for the tools and services used by GitHub1s. -### Tampermonkey scripts +## ๐Ÿ“„ License -- [Mr-B0b/TamperMonkeyScripts/vscode.js](https://github.com/Mr-B0b/TamperMonkeyScripts/blob/main/vscode.js) -
+[MIT](LICENSE) diff --git a/docs/ai.md b/docs/ai.md new file mode 100644 index 000000000..6220819df --- /dev/null +++ b/docs/ai.md @@ -0,0 +1,116 @@ +# GitHub1s AI + +[Documentation](guide.md) ยท [Using GitHub1s](usage.md) + +GitHub1s AI is the built-in assistant for asking questions about a repository. It can explain attached files or selections and use repository tools to find relevant code. You configure the model endpoint and API key used for each conversation. + +## Configure a model + +1. Click **Toggle Secondary Side Bar** in the layout controls at the top of GitHub1s to open the AI panel. +2. Click the gear icon at the top of the AI panel, then select **Models โ†’ Add model**. +3. Enter a **Name**, choose a **Provider** and, where available, a **Protocol**. +4. Set the **Base URL**, **API key**, and **Model ID** for an endpoint you can access from your browser. +5. Select **Save Model**, then click **Back to chat**. + +You can also open the Command Palette (`F1`) and run **GitHub1s AI: Open Chat** or **GitHub1s AI: Open AI Settings**. + +The first saved model is selected automatically. With multiple configurations, use the model selector in the chat composer to choose one. + +### Providers and protocols + +| Provider | Default protocol | Other supported protocols | Default base URL | +| --------- | ----------------------- | ------------------------------------ | ------------------------------ | +| Custom | OpenAI Chat Completions | OpenAI Responses, Anthropic Messages | Supply your endpoint | +| OpenAI | OpenAI Responses | OpenAI Chat Completions | `https://api.openai.com/v1` | +| Anthropic | Anthropic Messages | โ€” | `https://api.anthropic.com/v1` | + +Use the API base URL, such as `https://api.example.com/v1`, rather than a complete generation-request URL. The URL must use HTTP or HTTPS and cannot contain credentials, a query string, or a fragment. Use a model ID available through your endpoint. + +### Browser access + +Model requests originate in your browser. The endpoint must support browser requests, including the necessary cross-origin resource sharing (CORS) headers. An endpoint that works from a server-side script may still reject a browser request. Local endpoints can also be affected by the browser's HTTPS and local-network access rules. + +## Ask questions with context + +Start with **Repository overview**, **Explain current file**, or **Explain selection**, or type a question in the chat composer. + +Use the composer toolbar's **Add fileโ€ฆ**, **Attach current file**, or **Attach current selection** buttons. The editor action **Add to GitHub1s AI Chat** attaches the selection when one exists, or the current file otherwise. + +Attached content is read when you send the message. Remove a pending attachment with its remove button before sending. Click an attachment chip to open its source file or selection. + +**Include recent files** is enabled by default. It adds up to five recently viewed file paths to the model's context; the assistant can then read those files with its tools. Turn it off in the composer when those paths are not relevant to the question. + +**New Chat** starts a separate conversation and clears pending attachments. Previous conversations remain available through **GitHub1s AI: Show History**. + +### Customize responses + +In **AI Settings โ†’ Prompts**, add preferences such as the response language under **User Rules**, then select **Save prompts**. **Instructions** replaces the default assistant instructions, and **Quick Actions** customizes the starter questions. Leave instruction or quick-action fields empty to use their defaults. **Reset** updates the form; select **Save prompts** to save the reset values. + +## Repository tools + +The assistant has four built-in tools: + +| Tool | Purpose | +| -------- | ------------------------------------ | +| `read` | Read a text file or a range of lines | +| `ls` | List a directory | +| `glob` | Find files by a path pattern | +| `search` | Search text in files | + +These tools read within the open workspace. Results are bounded: for example, `read` accepts files up to 5 MiB and returns at most 2,000 lines per call. Long lines and tool output can be truncated, and searches depend on the workspace's search provider. These are tool limits; attached files use a separate context-loading path. + +For a large repository, ask about a specific directory, file, or symbol so the assistant can retrieve focused context. + +## Connect MCP servers + +Model Context Protocol (MCP) servers add tools to the chat. Open **GitHub1s AI: Open AI Settings**, select **MCP**, and enter a configuration such as: + +```json +{ + "mcpServers": { + "example": { + "type": "http", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } +} +``` + +Replace the example URL and token, then select **Save MCP settings**. Changes apply to the next response. Supported transports are `http` and `sse`; local `stdio` servers are unavailable in the browser. Headers are optional, and the server must allow browser access. + +Configured MCP tools become available to the assistant. Their capabilities determine which external actions it can perform; they are not restricted by the built-in repository tools' read-only behavior. Connect servers whose tools you intend the assistant to use. + +Save `{ "mcpServers": {} }` to remove all configured MCP connections. + +## Data and storage + +| Data | Where it goes | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Model configuration, including API keys | Saved in browser-backed VS Code extension global state; the selected credentials are used for model requests | +| Messages, attached code, recent file paths, and retrieved tool results | Included in requests to the configured model endpoint as conversation context | +| Conversation history | Saved in browser-backed extension storage, separated by workspace | +| MCP configuration and credentials | Saved in extension global state; used to connect to the configured servers | +| MCP tool calls | Sent to the corresponding server; their results become available to the model | + +The model and MCP services receive the data sent to them under their own service policies. Select endpoints appropriate for the repository content you plan to discuss. + +### Manage saved data + +- **Delete one conversation:** open **Show History**, use its delete action, and confirm **Delete**. +- **Export conversations:** in AI Settings, open **General โ†’ Export history โ†’ Export**. The JSON contains conversation data for the current workspace, including stored message and tool content. +- **Reset AI data:** in **General**, select **Clear all data** and confirm. This removes local AI conversations across all workspaces, model settings and API keys, MCP settings, and prompt settings. + +## Troubleshooting + +| Message or symptom | What to check | +| ------------------------------------------ | ----------------------------------------------------------------------------------- | +| `Select a model before sending a message.` | Add a model and select it in the composer | +| Authentication failure | Check the API key and the endpoint account's access | +| Endpoint or model not found | Check the base URL, protocol, and model ID | +| Unable to reach the endpoint | Check network access, CORS, and browser restrictions on the endpoint | +| Rate limit reached | Follow the model service's retry guidance | +| Missing repository context | Attach the relevant file or selection; check whether repository search is available | +| Unable to load MCP tools | Check the server URL, credentials, CORS support, and tool-name conflicts | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..08628c81f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,74 @@ +# Architecture + +[Documentation](guide.md) ยท [Development](development.md) ยท [Deployment](deployment.md) + +GitHub1s combines a browser build of VS Code with extensions that expose remote repositories as read-only workspaces. The browser loads files on demand through platform APIs and external code services. Pages Functions handle OAuth callbacks and a GitHub code-search proxy; a separate Worker supplies repository discovery snapshots. + +## Components + +| Component | Responsibility | Source | +| -------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------- | +| Web application | Select the platform and workspace, load VS Code, and bridge browser authentication | [`src/`](../src/) | +| VS Code web package | Build and package VS Code with GitHub1s-specific source overlays | [`vscode-web/`](../vscode-web/) | +| Repository extension | Route repository URLs, provide files and search, and display history and diffs | [`extensions/github1s/`](../extensions/github1s/) | +| AI extension | Manage chat, model connections, repository context, and optional MCP tools | [`extensions/github1s-ai/`](../extensions/github1s-ai/) | +| Pages Functions | Exchange OAuth authorization codes and proxy GitHub REST code search | [`functions/`](../functions/) | +| Discovery Worker | Collect public repositories and publish scheduled snapshots | [`workers/discovery/`](../workers/discovery/) | + +The root `src/` directory is the application entry point. VS Code source overlays live under `vscode-web/src/`. + +## Opening a repository + +1. [`src/index.ts`](../src/index.ts) selects a platform from the hostname and extracts the repository or package from the path. +2. [`src/config.ts`](../src/config.ts) creates the workspace configuration. GitHub, GitLab, and npm use different URI schemes; the GitHub home page opens Discovery. +3. The [repository extension](../extensions/github1s/src/extension.ts) registers adapters and VS Code providers. +4. A platform adapter parses the URL and supplies a data source. VS Code requests files, directory listings, or search results through the corresponding provider. +5. The extension fetches data and presents it in the editor, Explorer, or source control views. + +The file system providers are registered as read-only. The separate `/editor` entry opens an editor workspace without mounting a remote repository. + +```mermaid +flowchart TD + Browser[Browser application] --> Workbench[VS Code workbench] + Workbench --> Repository[Repository extension] + Workbench --> AI[AI extension] + Repository --> APIs[GitHub / GitLab / npm data services] + Repository --> Search[Sourcegraph / searchcode] + Repository --> Proxy[Pages Function: code search] + Proxy --> GitHub[GitHub REST API] + Browser --> OAuth[Pages Functions: OAuth callbacks] + OAuth --> Identity[GitHub / GitLab OAuth] + Repository --> Snapshot[Discovery snapshot endpoint] + AI --> Context[Workspace files and search] + Context --> Repository + AI --> Model[Configured model endpoint] + AI --> MCP[Configured MCP servers] +``` + +## Repository adapters and search + +Adapters separate platform behavior from VS Code providers. Each adapter supplies URL parsing, data access, and the views supported by that platform. Start with the [adapter types](../extensions/github1s/src/adapters/types.ts) and [adapter registration](../extensions/github1s/src/adapters/index.ts) when changing a platform integration. + +GitHub data access uses REST and GraphQL. When **Prefer to use Sourcegraph API** is enabled, supported operations try Sourcegraph first. GitHub text search then falls back to searchcode, followed by GitHub REST code search through the same-origin `/api/github/search/code` proxy. Definition, reference, and hover results use Sourcegraph separately. + +These services have different indexing, authentication, and query capabilities. The [usage guide](usage.md#search-and-code-navigation) describes the user-visible limitations. The current GitHub fallback sequence is implemented in [`data-source.ts`](../extensions/github1s/src/adapters/github1s/data-source.ts). + +## Authentication and data flow + +Repository tokens are persisted through VS Code extension global state in the browser. Direct authenticated repository API requests include the relevant token. The GitHub search fallback also sends the authorization header to the same-origin Pages Function, which forwards it to GitHub. + +OAuth uses a popup and a server-side callback. The callback exchanges the authorization code for a token, then returns the result to the browser. OAuth application secrets belong to the callback runtime. See [deployment configuration](deployment.md#configure-oauth) and [user authentication](usage.md#authentication-and-private-repositories). + +The AI extension has a separate model configuration and conversation store. It sends conversation context and tool results to the selected model endpoint and can connect to user-configured MCP servers. See [AI data handling](ai.md#data-and-storage) for the boundaries users need to understand. + +## Repository discovery + +The Discovery Worker runs GitHub Search queries on a schedule, combines their results into a snapshot, and stores it in Workers KV. The browser's Discovery adapter reads the public snapshot endpoint and renders repository collections as a virtual workspace. + +This service is separate from repository file access. Its configuration, collection rules, and refresh behavior are documented in the [Discovery Worker README](../workers/discovery/README.md). + +## Builds and source overlays + +The normal application build uses the published `@github1s/vscode-web` package, compiles the local extensions, and bundles the web entry with webpack. Output goes to `dist/`, with assets grouped under a directory derived from the Git commit. + +The `vscode-web/` build clones the revision recorded in [`.VERSION`](../vscode-web/.VERSION), applies source overlays, and packages the result. Keeping that build separate lets most changes use the prebuilt editor. Changes to VS Code itself use the [full VS Code development workflow](development.md#develop-with-a-local-vs-code-build). diff --git a/docs/community.md b/docs/community.md new file mode 100644 index 000000000..e719ec029 --- /dev/null +++ b/docs/community.md @@ -0,0 +1,51 @@ +# Community + +[Documentation](guide.md) ยท [GitHub1s](../README.md) + +## Contributing and feedback + +Report problems or propose improvements in the [issue tracker](https://github.com/conwnet/github1s/issues). +For a bug report, include the repository URL, steps to reproduce it, browser version, and the error message. +Remove tokens and other credentials from logs and screenshots before sharing them. + +To work on the project, start with the [development guide](development.md). + +## Third-party projects + +These independently maintained browser extensions and scripts offer shortcuts to GitHub1s. +See each project's documentation for installation and support. + +### Chrome extensions + +- [Repositree](https://chrome.google.com/webstore/detail/repositree/lafjldoccjnjlcmdhmniholdpjkbgajo) ([chouglesaud/repositree](https://github.com/chouglesaud/repositree)) +- [github-code-viewer](https://chrome.google.com/webstore/detail/github-code-viewer/ecddapgifccgblebfibdgkagfbdagjfn) ([febaoshan/edge-extensions-github-code-viewer](https://github.com/febaoshan/edge-extensions-github-code-viewer)) +- Github1s Extension ([Darkempire78/GitHub1s-Extension](https://github.com/Darkempire78/GitHub1s-Extension)) +- [Github Web IDE](https://chrome.google.com/webstore/detail/adjiklnjodbiaioggfpbpkhbfcnhgkfe) ([zvizvi/Github-Web-IDE](https://github.com/zvizvi/Github-Web-IDE)) +- [shortcut to github1s](https://chrome.google.com/webstore/detail/shortcut-to-github1s/gfcdbodapcbfckbfpmgeldfkkgjknceo) ([katsuhisa91/github1s-shortcut](https://github.com/katsuhisa91/github1s-shortcut)) +- [Github1s Shortcut - Open source](https://github.com/Fauzdar1/Github1s) +- [โšก๏ธ 1s to GitHub1s!](https://github.com/holazz/webext-github1s) +- [github1s Google Chrome Extensions](https://github.com/Lonely-Mr-zhang/github_1s_vscode) + +### Firefox extensions + +- [Repositree](https://addons.mozilla.org/en-US/firefox/addon/repositree/) ([chouglesaud/repositree](https://github.com/chouglesaud/repositree)) +- [Github1s Extension](https://addons.mozilla.org/firefox/addon/github1s-extension) ([Darkempire78/GitHub1s-Extension](https://github.com/Darkempire78/GitHub1s-Extension)) +- [Github1s](https://addons.mozilla.org/firefox/addon/github1s/) ([mcherifi/github1s-firefox-addon](https://github.com/mcherifi/github1s-firefox-addon)) +- [Github Web IDE](https://addons.mozilla.org/firefox/addon/github-web-ide/) ([zvizvi/Github-Web-IDE](https://github.com/zvizvi/Github-Web-IDE)) + +### Microsoft Edge extensions + +- [github-code-viewer](https://microsoftedge.microsoft.com/addons/detail/githubcodeviewer/jaaaapanahkknbgdbglnlchbjfhhjlpi) ([febaoshan/edge-extensions-github-code-viewer](https://github.com/febaoshan/edge-extensions-github-code-viewer)) +- [Github Web IDE](https://microsoftedge.microsoft.com/addons/detail/akjbkjciknacicbnkfjbnlaeednpadcf) ([zvizvi/Github-Web-IDE](https://github.com/zvizvi/Github-Web-IDE)) + +### Safari extension + +- [GitHub1s-For-Safari-Extension](https://apps.apple.com/us/app/readcodeonline/id1569026520?mt=12) ([code4you2021/GitHub1s-For-Safari-Extension](https://github.com/code4you2021/GitHub1s-For-Safari-Extension)) + +### Tampermonkey scripts + +- [Mr-B0b/TamperMonkeyScripts/vscode.js](https://github.com/Mr-B0b/TamperMonkeyScripts/blob/main/vscode.js) + +## Star history + +[![Stargazers over time](https://api.star-history.com/svg?repos=conwnet/github1s&type=Date)](https://star-history.com/#conwnet/github1s&Date) diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 000000000..6ee4a85df --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,121 @@ +# Deployment + +[Documentation](guide.md) ยท [Development](development.md) ยท [Architecture](architecture.md) + +A GitHub1s deployment consists of static application assets and, for OAuth and the GitHub REST search fallback, server-side Functions. Cloudflare Pages can host both. Repository discovery uses a separate Worker. + +## Deployment layout + +| Part | Location | Purpose | +| ------------------ | ----------------------- | --------------------------------------------------------------- | +| Application assets | Build output in `dist/` | VS Code, extensions, and the web entry | +| Pages Functions | Source in `functions/` | OAuth callbacks and GitHub code-search proxy | +| Discovery Worker | `workers/discovery/` | Scheduled repository collections and their public JSON endpoint | + +Serve the application at the root of an origin, such as `https://code.example.com`. Asset paths are absolute; deployment under a subdirectory requires code changes. + +Custom hostnames select the GitHub platform by default. GitLab and npm selection currently depends on the hostname rules in [`src/index.ts`](../src/index.ts); adapt those rules when hosting either platform on your own domain. + +## Configure OAuth + +OAuth configuration is needed for the **Connect to GitHub** and **Connect to GitLab** buttons. Users can also supply repository tokens manually. + +Create the corresponding OAuth application at the repository provider and register the callback on the same origin as GitHub1s: + +| Provider | Callback URL | +| -------- | --------------------------------------------------- | +| GitHub | `https://code.example.com/api/github-auth-callback` | +| GitLab | `https://code.example.com/api/gitlab-auth-callback` | + +Replace the origin with your deployment's origin. Set the following variables for each provider you enable: + +| Variable | Stage | Value | +| -------------------------- | --------------------------- | ---------------------------------------- | +| `GITHUB_OAUTH_ID` | Build and Functions runtime | GitHub OAuth application's client ID | +| `GITHUB_OAUTH_SECRET` | Functions runtime | GitHub OAuth application's client secret | +| `GITHUB1S_ALLOWED_ORIGINS` | Functions runtime | Comma-separated allowed origins | +| `GITLAB_OAUTH_ID` | Build and Functions runtime | GitLab application's client ID | +| `GITLAB_OAUTH_SECRET` | Functions runtime | GitLab application's client secret | +| `GITLAB1S_ALLOWED_ORIGINS` | Functions runtime | Comma-separated allowed origins | + +Use the same client ID at build time and runtime. Allowed origins include the scheme and any port, with no path or trailing slash, for example `https://code.example.com,http://localhost:8788`. The callback checks its own request origin against this list and returns authorization to the browser on that origin. + +Set client IDs in the environment before building; webpack does not load them from `.dev.vars` or `.env` automatically. Configure runtime values in the Pages project's variables and secrets for the target environment. Keep OAuth application secrets in the Functions runtime. See [Cloudflare's Functions bindings guide](https://developers.cloudflare.com/pages/functions/bindings/#secrets) for runtime secrets. + +The repository also reads `GITHUB_DOMAIN`, `GITHUB_API_PREFIX`, `GITLAB_DOMAIN`, and `GITLAB_API_PREFIX` during builds. These customize some upstream URLs, but OAuth endpoints and the GitHub search proxy still target github.com and gitlab.com. Supporting an enterprise or self-managed provider requires reviewing those paths as well as platform selection. + +## Build and deploy to Pages + +Use the [development prerequisites](development.md#prerequisites). From the repository root, install dependencies and build with the client IDs for the providers you enabled: + +```bash +npm install +export GITHUB_OAUTH_ID='your-github-client-id' +npm run build +``` + +Omit the export for a deployment without GitHub OAuth; set `GITLAB_OAUTH_ID` as well when enabling GitLab OAuth. + +For a CLI deployment, authenticate with Cloudflare and create a Pages project if needed: + +```bash +npx wrangler login +npx wrangler pages project create +``` + +Configure the project's runtime variables and secrets, then deploy: + +```bash +npx wrangler pages deploy dist --project-name your-pages-project +``` + +Run this command from the repository root so Wrangler also compiles and uploads `functions/`. Uploading only `dist/` through the dashboard does not include these Functions. See [Pages Direct Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/#functions). + +With Pages Git integration, use the repository root as the build root, `npm run build` as the build command, and `dist` as the output directory. Set the Node version to the CI baseline documented in the development guide. + +## Preview Pages Functions locally + +Wrangler can run the built assets and Functions together. Prepare OAuth configuration before building or starting it if you want to test authentication. + +For OAuth, provide the runtime variables in a root `.dev.vars` file. Before creating it, add `.dev.vars*` to your local `.git/info/exclude`; the repository's `.gitignore` does not currently cover it. An example for GitHub is: + +```dotenv +GITHUB_OAUTH_ID=your-github-client-id +GITHUB_OAUTH_SECRET=your-github-client-secret +GITHUB1S_ALLOWED_ORIGINS=http://localhost:8788 +``` + +Register `http://localhost:8788/api/github-auth-callback` with the OAuth application used for local testing. Then, from the repository root, export that application's client ID, rebuild, and start the preview: + +```bash +export GITHUB_OAUTH_ID='your-github-client-id' +npm run build +npx wrangler pages dev dist +``` + +Omit the OAuth configuration and export for a preview without GitHub OAuth. Open `http://localhost:8788/conwnet/github1s`. See [Pages local development](https://developers.cloudflare.com/pages/functions/local-development/) and [local secrets](https://developers.cloudflare.com/pages/functions/bindings/#local-development-with-secrets). + +## Other static hosts + +To serve the application assets on another host: + +- Publish `dist/` at the origin root. +- Serve `index.html` for application routes such as `/owner/repo/blob/ref/path`, while serving real assets directly. +- Apply the response headers from [`public/_headers`](../public/_headers): `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: credentialless`. +- Provide equivalent server routes for OAuth callbacks and `/api/github/search/code` if those features are required. + +Pages supplies an [SPA fallback](https://developers.cloudflare.com/pages/configuration/serving-pages/#single-page-application-spa-rendering) when no top-level `404.html` exists. Its [`_headers` rules](https://developers.cloudflare.com/pages/configuration/headers/) apply to static assets; Functions manage their own responses. + +## Discovery service + +The application reads Discovery from a URL defined in [`extensions/github1s/src/adapters/discovery/data-source.ts`](../extensions/github1s/src/adapters/discovery/data-source.ts). To use your own service, deploy the [Discovery Worker](../workers/discovery/README.md), update that URL, and rebuild the application. + +## Verify the deployment + +1. Open a public repository and read a file. +2. Reload a deep file URL to verify route fallback and asset paths. +3. If OAuth is enabled, connect an account and validate its token in Settings. +4. Check the `/api/github/search/code` route. Successful search through another service alone does not verify the GitHub fallback. +5. Open the home page and confirm Discovery loads from the intended endpoint. + +AI model and MCP requests originate in the browser. Their connectivity depends on the endpoints configured by each user; see the [AI guide](ai.md). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 000000000..7512f4710 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,119 @@ +# Development + +[Documentation](guide.md) ยท [Architecture](architecture.md) ยท [Deployment](deployment.md) + +Most changes to GitHub1s use the published VS Code web package. Build VS Code locally when changing the editor source overlays under `vscode-web/`. + +## Prerequisites + +- Git and npm. +- Node.js 24, matching the repository's CI configuration. +- A browser for testing the application. + +The commands below assume a Unix-compatible shell. Run them from the repository root unless a different working directory is shown. + +## Run locally + +```bash +git clone https://github.com/conwnet/github1s.git +cd github1s +npm install +npm run watch +``` + +The install step also installs dependencies for the local extensions. Watch mode starts webpack's development server and the repository and AI extension watchers. + +Wait for the application and both extensions to finish compiling, then open [localhost:8080/conwnet/github1s](http://localhost:8080/conwnet/github1s). The development server uses port `8080` and writes generated assets to `dist/`. + +OAuth callback Functions are not run by `npm run watch`; use a manually supplied token for repository authentication or follow the [Pages development instructions](deployment.md#preview-pages-functions-locally). + +## Build the application + +```bash +npm run build +``` + +This compiles the local extensions and produces the application in `dist/`. See the [deployment guide](deployment.md) for hosting the assets and enabling the accompanying Functions. + +## Check changes + +Choose checks for the part of the project you changed: + +| Command | Coverage | +| --------------------------------------------------- | ----------------------------------------------- | +| `npm run eslint:check` | Repository lint checks, without automatic fixes | +| `npm run typecheck` | Root application and Pages Functions | +| `npm --prefix extensions/github1s run test` | Repository extension tests | +| `npm --prefix extensions/github1s-ai run typecheck` | AI extension and webview types | +| `npm --prefix extensions/github1s-ai run test` | AI extension tests | +| `npm run build` | Production compilation and packaging | + +For browser integration tests, build the application first, stop any server already using port `8080`, then run: + +```bash +npm run build +npm run test:ci +``` + +The test command starts the development server, installs the test dependencies and Playwright browsers, and runs the Jest browser suite. The existing tests launch Chromium, exercise repository loading and navigation, and depend on external repository services. + +`npm run format` formats the whole repository, and `npm run eslint` applies lint fixes. To format only a documentation change, pass the changed files to Prettier: + +```bash +npx prettier --write README.md docs/usage.md +``` + +## Develop with a local VS Code build + +This workflow requires the native build tools used by the pinned VS Code revision. Follow the [VS Code package guide](../vscode-web/README.md) for upstream prerequisites and overlay maintenance. + +Install and build the companion package: + +```bash +npm install +cd vscode-web +npm install +npm run build +cd .. +npm run link +``` + +The package build clones VS Code and its localization repository, applies the overlays, compiles the editor, and writes `vscode-web/dist/`. The link command makes the root application use that package. + +Run the VS Code watchers in one terminal: + +```bash +cd vscode-web +npm run watch +``` + +Run the application and extension watchers from the repository root in another terminal: + +```bash +npm run watch-with-vscode +``` + +Wait for both terminals to finish their initial compilation, then open the same local application URL. In this mode, the development server serves editor assets from `vscode-web/lib/vscode/`. + +To test a production build using the locally built package, run `npm run build` from the repository root after building and linking `vscode-web`. + +## Where to make changes + +| Change | Location | +| ---------------------------------------------------------- | ------------------------------- | +| Application startup, workspace selection, or browser OAuth | `src/` | +| Repository access, routing, history, or search | `extensions/github1s/src/` | +| AI chat, model connections, context, or tools | `extensions/github1s-ai/src/` | +| VS Code behavior modified by GitHub1s | `vscode-web/src/` | +| OAuth callback or GitHub search proxy | `functions/` | +| Repository discovery collections or snapshots | `workers/discovery/` | +| Build and packaging behavior | `scripts/`, `webpack.config.js` | + +See the [architecture guide](architecture.md) for how these components fit together. + +## Troubleshooting + +- **Missing files during startup:** check that both extension watchers and webpack have completed compilation, then reload the browser. +- **OAuth fails locally:** the webpack server does not host the OAuth callbacks. Use a token or the Pages preview workflow. +- **Changes to VS Code do not appear:** use the two-terminal workflow above and edit the overlays under `vscode-web/src/`. +- **A root type check passes but AI code fails:** the AI extension has its own type checks; run its `typecheck` script. diff --git a/docs/guide.md b/docs/guide.md index 4c30e8934..d8c06cf84 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1,86 +1,18 @@ -# How it works +# Documentation -GitHub1s is based on [VS Code 1.66.2](https://github.com/microsoft/vscode/tree/1.66.2) now. VS Code can be built for a browser version officially. I also used the code and got inspired by [Code Server](https://github.com/cdr/code-server). +[GitHub1s](../README.md) -Thanks to the very powerful and flexible extensibility of VS Code, we can easily implement a VS Code extension that provides the custom File IO ability using [FileSystemProvider API](https://code.visualstudio.com/api/references/vscode-api#FileSystemProvider). There is an official demo named [vscode-web-playground](https://github.com/microsoft/vscode-web-playground) which shows how it is used. +The original guide has been reorganized into focused documents: -On the other hand, GitHub provides the powerful [REST API](https://docs.github.com/en/rest) that can be used for a variety of tasks which includes reading directories and files for sure. +- [Using GitHub1s](usage.md): repository navigation, authentication, search, and troubleshooting. +- [GitHub1s AI](ai.md): configure a model, add context, connect tools, and manage stored data. +- [Development](development.md): run the application, build it, and check changes. +- [Architecture](architecture.md): understand the components and their data flow. +- [Deployment](deployment.md): host the application and configure its services. +- [Community](community.md): find third-party extensions and view star history. -According to the above, obviously, the core concept of GitHub1s is to implement a VS Code Extension (includes FileSystemProvider) using GitHub REST API. +## Component documentation -_We may switch to the GitHub GraphQL API for more friendly user experience in the future, thanks to @xcv58 and @kanhegaonkarsaurabh. See details at [Issue 12](https://github.com/conwnet/github1s/issues/12)._ - -~~GitHub1s is a purely static web app (because it really doesn't need a backend service, does it?). So we just deploy it on [GitHub Pages](https://pages.github.com/) now (the `gh-pages` branch of this repository), and it is free. The service of GitHub1s could be reliable (GitHub is very reliable) because nobody needs to pay the web hosting bills.~~ - -We deploy GitHub1s on [Cloudflare Pages](https://cloudflare.com/) now for minimize delays in loading and better developer experience. Thanks for the wonderful service provide by Cloudflare. - -# Rate Limit - -Another thing that needs attention is [Rate Limit](https://docs.github.com/en/rest/reference/rate-limit): - -> For unauthenticated requests, the rate limit allows for up to 60 requests per hour. Unauthenticated requests are associated with the originating IP address, and not the user making requests. - -> For API requests using Basic Authentication or OAuth, you can make up to 5,000 requests per hour. - -So, if you meet some problems when you use GitHub1s, even if you are using newer browsers, you could try to set a [GitHub OAuth Token](https://docs.github.com/en/rest/overview/resources-in-the-rest-api#oauth2-token-sent-in-a-header). Don't worry, we cannot see your token. It is only stored in your browser [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) with [VS Code Extension globalState API](https://code.visualstudio.com/api/references/vscode-api#ExtensionContext) (Actually we don't have a server, do we?). - -But this does not mean the token is absolutely safe, **don't forget to clean it while you are using a device that doesn't belong to you**. - -# Sourcegraph API - -Due to the potential RateLimit of the GitHub API, we will prioritize the use of the [Sourcegraph API](https://sourcegraph.com/docs) for public repositories, with the exception of the Read interface, the code search capability is also provided by the Sourcegraph API. - -By default, GitHub1s will only try to use the GitHub API when the Sourcegraph API request fails, and you can adjust this option in the settings. - -# Development - -As you see, running GitHub1s locally is not difficult. After cloning the repository, just run these commands: - -```shell -$ npm install -$ npm run watch -``` - -Then, there will be a new directory named `dist` generated in the project root. The `npm run watch:dev-server` (part of `npm run watch` command) will automatically open http://localhost:8080 in the browser. - -If you get a 404 error for some static files, please wait a minute for the building to complete. - -## Watch Mode - -What happens after you run `npm run watch-with-vscode`? - -1. Copy some necessary resources (`index.html`, `favicons.ico`, etc.) to the `dist` directory. - -2. This command will compile the codes in `src` and generate application entry script (see `webpack.config.js`). This command also compile the custom extensions (for example `github1s`) in `extensions` directory. - -3. Redirect vscode-web static requests (vscode, extensisions, dependencies) to `vscode-web/lib/vscode` which should be generated by vscode compile process. - -You should also compile the vscode manually in another terminal. - -1. Go to `vscode-web` and run `npm install && npm run watch` (the native watch of vscode), it will trigger a new build if something in it has been changed. - -2. This command will alose watch the `vscode-web/src` and `vscode-web/extensions` directory, merge it in to `vscode-web/lib/vscode` if something in it has been changed. (When a new file is merged into `lib/vscode`, it will trigger the watcher that is described in Step 3) - -Note that since we have modified the source code of VS Code, it may get into trouble when merging a newer version VS Code. - -It is a little laborious to complete the watch process, but I didn't think of a better solution. - -What happens after you run `npm run watch`? - -It's the same procedure as `` without the step 3. Instead of the local VS Code, it uses the prebuilt [@github1s/vscode-web](https://www.npmjs.com/package/@github1s/vscode-web) version. - -## Build mode - -Put simply, we build the necessary code and do a minify. The minify script is modified from [Code Server](https://github.com/cdr/code-server). - -## Directory Structure - -- `extensions` - custom VS Code extensions that don't come with VS Code natively. - -- `src` - the code in here will be patched into VS Code source. - -- `vscode-web` - This contains the code to patch VS Code. - -- `scripts` - some scripts for build, watch, package, etc. - -- `resources` - some resource files such as templates, pictures, configuration files, etc. +- [GitHub1s AI extension](../extensions/github1s-ai/README.md) +- [VS Code web package](../vscode-web/README.md) +- [Discovery Worker](../workers/discovery/README.md) diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 000000000..e87a68466 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,117 @@ +# Using GitHub1s + +[Documentation](guide.md) ยท [AI guide](ai.md) + +GitHub1s opens remote repositories in a VS Code interface in your browser. Public repositories can be browsed without signing in, subject to the upstream services' access and rate limits. + +## Open a repository or package + +For GitHub, add `1s` after `github` in the address bar. For GitLab or npm, add `1s` after `gitlab` or `npmjs`. + +| Source | Example | +| ----------------- | ---------------------------------------- | +| GitHub repository | `https://github1s.com/microsoft/vscode` | +| GitLab repository | `https://gitlab1s.com/gitlab-org/gitlab` | +| npm package | `https://npmjs1s.com/package/lodash` | + +Opening [GitHub1s without a repository path](https://github1s.com) displays repository collections from GitHub Discovery. Within a repository, use **GitHub1s: Open Repository...** in the Command Palette and enter a repository name such as `owner/repo` for the current platform. Use the browser address bar for a complete URL. + +### Links to files and changes + +GitHub1s recognizes common repository URLs. Replace the placeholders below with a repository, branch, tag, commit, or request number: + +| View | URL pattern | +| ------------------------------ | ------------------------------------------------------------ | +| GitHub branch or directory | `https://github1s.com/owner/repo/tree/ref/path` | +| GitHub file and selected lines | `https://github1s.com/owner/repo/blob/ref/path#L10-L20` | +| GitHub commit | `https://github1s.com/owner/repo/commit/sha` | +| GitHub pull request | `https://github1s.com/owner/repo/pull/number` | +| GitLab file | `https://gitlab1s.com/group/project/-/blob/ref/path` | +| GitLab merge request | `https://gitlab1s.com/group/project/-/merge_requests/number` | + +For branches or tags, you can also run **GitHub1s: Checkout to...**. Its **Checkout detached** option accepts a ref directly. This changes the revision being browsed. + +## Navigate code and history + +- Use **Explorer** to browse files and directories, or **Go to File** to find a file by name. +- Use **Search** for text across the repository and the editor's **Find** action for the open file. +- Open a file and expand **Timeline** in **Explorer** to inspect its history. Select a commit entry to open that file's diff. +- In **Source Control**, use the built-in **Graph** view to browse commits for the current ref and inspect their changed files and diffs. +- In **Source Control**, select a pull/merge request from **Pull Requests** (GitHub) or **Merge Requests** (GitLab) to inspect its changed files and diffs. +- Run **GitHub1s: Toggle File Blame** to display revision information beside file lines where available. + +Open the Command Palette with `F1`, `Ctrl+Shift+P`, or `Cmd+Shift+P` on macOS. Browser and operating-system shortcuts can take precedence. + +For questions about the code, click **Toggle Secondary Side Bar** in the layout controls at the top of GitHub1s to open the AI panel, then follow the [AI setup guide](ai.md#configure-a-model). + +## Authentication and private repositories + +Use authentication to access private repositories or make authenticated API requests. The account or token must have access to the repository you want to open. + +1. Open **Settings** in the left activity bar. +2. Choose **Connect to GitHub** or **Connect to GitLab**, then complete the authorization flow in the popup. +3. Alternatively, enter a token in **Manual Input AccessToken** and select **Submit**. **Create New AccessToken** opens the corresponding provider's token creation page. +4. After connecting, use **Validate** to check the token and reopen the repository if necessary. + +### Token storage and requests + +GitHub and GitLab tokens are stored separately in the browser's VS Code extension state. Authenticated repository requests send the relevant token to the repository provider. Two additional paths matter: + +- OAuth callbacks exchange authorization codes for tokens on the site's server-side Functions. +- GitHub's REST code-search fallback sends the authorization header through the site's same-origin search proxy, which forwards it to GitHub. +- The above features are all Cloudflare Functions now, which you can review in the [functions](../functions/) directory + +To remove a saved token, choose **Clear**, then **Confirm**, in Settings. This removes the browser's saved token. Revoke the token or application authorization in GitHub if you also want to withdraw its access at the provider. + +## Search and code navigation + +Repository-wide search relies on external services. For GitHub, **Prefer to use Sourcegraph API** in Settings enables an initial [Sourcegraph](https://sourcegraph.com/) attempt. Search then falls back to [searchcode](https://searchcode.com/) and, if needed, the [GitHub REST API](https://docs.github.com/en/rest/search/search#search-code) through the site's proxy. + +Results depend on the service's repository coverage, indexed revision, permissions, and rate limits. In particular: + +- The GitHub REST fallback uses default-branch search results; they can differ from the ref currently open in the editor. +- That fallback does not support regular-expression or multiline searches, and result positions derived from snippets can be approximate. +- GitHub definition, reference, and hover results depend on Sourcegraph availability and coverage. +- Successful file browsing does not guarantee that repository-wide search or symbol navigation is available, especially for private repositories. + +Authentication can help with API access and rate limits, but each provider and endpoint applies its own limits. Follow the error's retry guidance rather than assuming a single request quota covers every feature. + +## Capabilities and limits + +| Area | Scope | +| ------------------------- | ------------------------------------------------------------------------------------------ | +| GitHub and GitLab | Read repository files and inspect supported history and code-review views | +| AI | Requires a configured model and API key; see [AI setup](ai.md) | +| npm | Browse published package files and versions | +| Remote repository changes | Read-only; use the repository host or a development environment to edit and submit changes | +| Extensions | Browser-compatible extensions; availability differs from desktop VS Code | + +## Troubleshooting + +| Symptom | What to check | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| A private repository does not open | Validate the token, check repository access, and confirm the URL works on the provider's site | +| Requests are rate limited | Authenticate if appropriate, inspect the reported limit, and wait for the retry time | +| Search is empty or fails while files load | Try a simple text query; check the selected ref and the service limitations above | +| OAuth does not complete | Allow the authentication popup; on a self-hosted instance, check [OAuth configuration](deployment.md#configure-oauth) | +| AI cannot connect | Check the endpoint, model ID, API key, and browser access requirements in the [AI guide](ai.md#troubleshooting) | + +For an unresolved problem, [open an issue](https://github.com/conwnet/github1s/issues) with the repository URL, browser version, and steps to reproduce it. Remove credentials from any logs or screenshots you include. + +## Browser shortcuts + +The [community directory](community.md#third-party-projects) lists browser extensions and scripts. You can also save this JavaScript as a bookmark's URL to switch between GitHub and GitHub1s while preserving the repository path: + +```javascript +javascript: (() => { + const url = new URL(window.location.href); + if (url.hostname === 'github.com') { + url.hostname = 'github1s.com'; + } else if (url.hostname === 'github1s.com') { + url.hostname = 'github.com'; + } else { + return; + } + window.location.href = url.href; +})(); +``` diff --git a/extensions/github1s-ai/README.md b/extensions/github1s-ai/README.md index 6e9ab75a7..9dbb95171 100644 --- a/extensions/github1s-ai/README.md +++ b/extensions/github1s-ai/README.md @@ -1,3 +1,13 @@ # GitHub1s AI -GitHub1s AI is a lightweight, built-in chat view for asking LLM about a repository. +GitHub1s AI is the built-in assistant for exploring code in GitHub1s. It combines chat, file and selection attachments, repository tools, and optional MCP connections with a user-configured model endpoint. + +## Use the assistant + +Open **GitHub1s AI: Open AI Settings** from the Command Palette to add a model, then run **GitHub1s AI: Open Chat**. + +See the [AI guide](https://github.com/conwnet/github1s/blob/master/docs/ai.md) for model configuration, context, tools, and data handling. + +## Development + +The extension requires GitHub1s's browser workbench and repository providers. See the [development guide](https://github.com/conwnet/github1s/blob/master/docs/development.md) for running the complete application. diff --git a/extensions/github1s/package-lock.json b/extensions/github1s/package-lock.json index 7bf1546e9..8266fd138 100644 --- a/extensions/github1s/package-lock.json +++ b/extensions/github1s/package-lock.json @@ -31,7 +31,7 @@ "webpack-cli": "^4.9.2" }, "engines": { - "vscode": "^1.48.0" + "vscode": "^1.96.0" } }, "node_modules/@apollo/client": { diff --git a/extensions/github1s/package.json b/extensions/github1s/package.json index cec49ada6..6ce5881c1 100644 --- a/extensions/github1s/package.json +++ b/extensions/github1s/package.json @@ -7,7 +7,10 @@ "enabledApiProposals": [ "fileSearchProvider", "textSearchProvider", - "resolvers" + "scmHistoryProvider", + "contribSourceControlHistoryTitleMenu", + "contribSourceControlHistoryItemMenu", + "timeline" ], "directories": { "lib": "lib" @@ -23,7 +26,7 @@ ], "browser": "./dist/extension", "engines": { - "vscode": "^1.48.0" + "vscode": "^1.96.0" }, "contributes": { "resourceLabelFormatters": [ @@ -66,24 +69,16 @@ { "id": "github1s.views.settings", "name": "Settings", + "icon": "$(settings-gear)", "type": "webview", "when": "github1s:views:settings:visible == true" } ], "scm": [ - { - "id": "github1s.views.fileHistory", - "name": "File History", - "when": "github1s:views:fileHistory:visible == true" - }, - { - "id": "github1s.views.commitList", - "name": "Commits", - "when": "github1s:views:commitList:visible == true" - }, { "id": "github1s.views.codeReviewList", "name": "Code Reviews", + "icon": "$(git-pull-request)", "when": "github1s:views:codeReviewList:visible == true" } ] @@ -172,18 +167,6 @@ "icon": "$(globe)", "enablement": "github1s:adapters:default:platformName != 'GitHub' && github1s:adapters:default:platformName != 'GitLab' && github1s:adapters:default:platformName != 'Bitbucket'" }, - { - "command": "github1s.commands.refreshFileHistoryCommitList", - "title": "Refresh", - "category": "GitHub1s", - "icon": "$(refresh)" - }, - { - "command": "github1s.commands.refreshCommitList", - "title": "Refresh", - "category": "GitHub1s", - "icon": "$(refresh)" - }, { "command": "github1s.commands.searchCommit", "title": "Search", @@ -196,12 +179,6 @@ "category": "GitHub1s", "icon": "$(log-in)" }, - { - "command": "github1s.commands.diffCommitFile", - "title": "Diff File", - "category": "GitHub1s", - "icon": "$(diff)" - }, { "command": "github1s.commands.openCommitOnGitHub", "title": "Open on GitHub", @@ -244,7 +221,7 @@ "dark": "assets/icons/dark/open-left-file.svg", "light": "assets/icons/light/open-left-file.svg" }, - "enablement": "isInDiffEditor && resourceScheme =~ /^(github1s|gitlab1s|bitbucket1s)/ && resource =~ /^(?![^?]*\\?[^#]*(%26|\\b)base(=|%3D|%3d)github1s-empty-file)/" + "enablement": "isInDiffEditor && diffEditorOriginalUri && !(diffEditorOriginalUri =~ /^github1s-empty-file:/)" }, { "command": "github1s.commands.diffViewOpenRightFile", @@ -254,21 +231,21 @@ "dark": "assets/icons/dark/open-right-file.svg", "light": "assets/icons/light/open-right-file.svg" }, - "enablement": "isInDiffEditor && resourceScheme =~ /^(github1s|gitlab1s|bitbucket1s)/ && resource =~ /^(?![^?]*\\?[^#]*(%26|\\b)head(=|%3D|%3d)github1s-empty-file)/" + "enablement": "isInDiffEditor && diffEditorModifiedUri && !(diffEditorModifiedUri =~ /^github1s-empty-file:/)" }, { "command": "github1s.commands.openFilePreviousRevision", "title": "Open Previous Revision", "category": "GitHub1s", "icon": "$(arrow-left)", - "enablement": "resourceScheme =~ /^(github1s|gitlab1s|bitbucket1s)$/ && resource =~ /^(?![^?]*\\?[^#]*(%26|\\b)base(=|%3D|%3d)github1s-empty-file)/" + "enablement": "(!isInDiffEditor && resourceScheme =~ /^(github1s|gitlab1s|bitbucket1s)$/) || (isInDiffEditor && diffEditorOriginalUri =~ /^(github1s|gitlab1s|bitbucket1s):/)" }, { "command": "github1s.commands.openFileNextRevision", "title": "Open Next Revision", "category": "GitHub1s", "icon": "$(arrow-right)", - "enablement": "resource =~ /^[^?]*\\?[^#]*(%26|\\b)hasNextRevision(=|%3D|%3d)true/" + "enablement": "resourceScheme =~ /^(github1s|gitlab1s|bitbucket1s)$/ && resource =~ /^[^?]*\\?([^#]*(&|%26))?hasNext(=|%3D|%3d)true/" }, { "command": "github1s.commands.toggleEditorGutterBlame", @@ -423,10 +400,6 @@ "command": "github1s.commands.openCodeReviewOnOfficialPage", "when": "false" }, - { - "command": "github1s.commands.refreshCommitList", - "when": "false" - }, { "command": "github1s.commands.searchCommit", "when": "false" @@ -486,26 +459,6 @@ "command": "github1s.commands.searchCodeReview", "when": "view == 'github1s.views.codeReviewList'", "group": "navigation@2" - }, - { - "command": "github1s.commands.refreshFileHistoryCommitList", - "when": "view == 'github1s.views.fileHistory'", - "group": "navigation@1" - }, - { - "command": "github1s.commands.searchCommit", - "when": "view == 'github1s.views.fileHistory'", - "group": "navigation@2" - }, - { - "command": "github1s.commands.refreshCommitList", - "when": "view == 'github1s.views.commitList'", - "group": "navigation@1" - }, - { - "command": "github1s.commands.searchCommit", - "when": "view == 'github1s.views.commitList'", - "group": "navigation@2" } ], "view/item/context": [ @@ -548,36 +501,6 @@ "command": "github1s.commands.openCodeReviewOnOfficialPage", "when": "viewItem == 'github1s:viewItems:codeReviewListItem' && github1s:adapters:default:platformName != 'GitHub' && github1s:adapters:default:platformName != 'GitLab' && github1s:adapters:default:platformName != 'Bitbucket' && github1s:adapters:default:platformName != 'npm'", "group": "inline@2" - }, - { - "command": "github1s.commands.switchToCommit", - "when": "viewItem == 'github1s:viewItems:commitListItem'", - "group": "inline@1" - }, - { - "command": "github1s.commands.openCommitOnGitHub", - "when": "viewItem == 'github1s:viewItems:commitListItem' && github1s:adapters:default:platformName == 'GitHub'", - "group": "inline@2" - }, - { - "command": "github1s.commands.diffCommitFile", - "when": "viewItem == 'github1s:viewItems:commitListItem' && view == 'github1s.views.fileHistory'", - "group": "inline@3" - }, - { - "command": "github1s.commands.openCommitOnGitLab", - "when": "viewItem == 'github1s:viewItems:commitListItem' && github1s:adapters:default:platformName == 'GitLab'", - "group": "inline@2" - }, - { - "command": "github1s.commands.openCommitOnBitbucket", - "when": "viewItem == 'github1s:viewItems:commitListItem' && github1s:adapters:default:platformName == 'Bitbucket'", - "group": "inline@2" - }, - { - "command": "github1s.commands.openCommitOnOfficialPage", - "when": "viewItem == 'github1s:viewItems:commitListItem' && github1s:adapters:default:platformName != 'GitHub' && github1s:adapters:default:platformName != 'GitLab' && github1s:adapters:default:platformName != 'Bitbucket' && github1s:adapters:default:platformName != 'npm'", - "group": "inline@2" } ], "editor/title": [ @@ -598,12 +521,12 @@ }, { "command": "github1s.commands.openFilePreviousRevision", - "when": "resourceScheme =~ /^(github1s|gitlab1s|bitbucket1s)/", + "when": "resourceScheme =~ /^(github1s|gitlab1s|bitbucket1s)$/ || (isInDiffEditor && diffEditorOriginalUri =~ /^(github1s|gitlab1s|bitbucket1s):/)", "group": "navigation@4" }, { "command": "github1s.commands.openFileNextRevision", - "when": "resourceScheme =~ /^(github1s|gitlab1s|bitbucket1s)/", + "when": "resourceScheme =~ /^(github1s|gitlab1s|bitbucket1s)$/ || (isInDiffEditor && diffEditorOriginalUri =~ /^(github1s|gitlab1s|bitbucket1s):/)", "group": "navigation@5" }, { @@ -616,6 +539,67 @@ "when": "!isInDiffEditor && github1s:features:gutterBlame:enabled && github1s:features:gutterBlame:open", "group": "navigation@6" } + ], + "scm/history/title": [ + { + "command": "github1s.commands.searchCommit", + "when": "scmProvider == github1s", + "group": "navigation@3" + } + ], + "scm/historyItem/context": [ + { + "command": "github1s.commands.switchToCommit", + "when": "scmProvider == github1s", + "group": "inline@1" + }, + { + "command": "github1s.commands.openCommitOnGitHub", + "when": "scmProvider == github1s && github1s:adapters:default:platformName == 'GitHub'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnGitLab", + "when": "scmProvider == github1s && github1s:adapters:default:platformName == 'GitLab'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnBitbucket", + "when": "scmProvider == github1s && github1s:adapters:default:platformName == 'Bitbucket'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnOfficialPage", + "when": "scmProvider == github1s && github1s:adapters:default:platformName != 'GitHub' && github1s:adapters:default:platformName != 'GitLab' && github1s:adapters:default:platformName != 'Bitbucket' && github1s:adapters:default:platformName != 'npm'", + "group": "inline@2" + } + ], + "timeline/item/context": [ + { + "command": "github1s.commands.switchToCommit", + "when": "timelineItem == 'github1s:timeline:commit'", + "group": "inline@1" + }, + { + "command": "github1s.commands.openCommitOnGitHub", + "when": "timelineItem == 'github1s:timeline:commit' && github1s:adapters:default:platformName == 'GitHub'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnGitLab", + "when": "timelineItem == 'github1s:timeline:commit' && github1s:adapters:default:platformName == 'GitLab'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnBitbucket", + "when": "timelineItem == 'github1s:timeline:commit' && github1s:adapters:default:platformName == 'Bitbucket'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnOfficialPage", + "when": "timelineItem == 'github1s:timeline:commit' && github1s:adapters:default:platformName != 'GitHub' && github1s:adapters:default:platformName != 'GitLab' && github1s:adapters:default:platformName != 'Bitbucket' && github1s:adapters:default:platformName != 'npm'", + "group": "inline@2" + } ] } }, diff --git a/extensions/github1s/src/adapters/bitbucket1s/index.ts b/extensions/github1s/src/adapters/bitbucket1s/index.ts index 8be7a8b89..5b1b845fe 100644 --- a/extensions/github1s/src/adapters/bitbucket1s/index.ts +++ b/extensions/github1s/src/adapters/bitbucket1s/index.ts @@ -6,7 +6,6 @@ import { BitbucketRouterParser } from './router-parser'; import { SourcegraphDataSource } from '../sourcegraph/data-source'; import { Adapter, CodeReviewType, PlatformName } from '../types'; -import { setVSCodeContext } from '@/helpers/vscode'; export class BitbucketAdapter implements Adapter { public scheme: string = 'bitbucket1s'; @@ -20,16 +19,4 @@ export class BitbucketAdapter implements Adapter { resolveRouterParser() { return Promise.resolve(BitbucketRouterParser.getInstance()); } - - activateAsDefault() { - setVSCodeContext('github1s:views:commitList:visible', true); - setVSCodeContext('github1s:views:fileHistory:visible', true); - setVSCodeContext('github1s:features:gutterBlame:enabled', true); - } - - deactivateAsDefault() { - setVSCodeContext('github1s:views:commitList:visible', false); - setVSCodeContext('github1s:views:fileHistory:visible', false); - setVSCodeContext('github1s:features:gutterBlame:enabled', false); - } } diff --git a/extensions/github1s/src/adapters/github1s/data-source.ts b/extensions/github1s/src/adapters/github1s/data-source.ts index 971befe02..ef6eb9e2e 100644 --- a/extensions/github1s/src/adapters/github1s/data-source.ts +++ b/extensions/github1s/src/adapters/github1s/data-source.ts @@ -281,7 +281,7 @@ export class GitHub1sDataSource extends DataSource { page: options?.page, per_page: options?.pageSize, sha: options?.from, - path: isString(options?.path) ? trimStart(options.path, '/') : undefined, + path: trimStart(options?.path || '', '/') || undefined, author: options?.author, }; const requestParams = { owner, repo, ...queryParams }; @@ -325,11 +325,11 @@ export class GitHub1sDataSource extends DataSource { async provideCommitChangedFiles( repoFullName: string, ref: string, - _options?: CommonQueryOptions, + options?: CommonQueryOptions, ): Promise { const fetcher = GitHubFetcher.getInstance(); const { owner, repo } = parseRepoFullName(repoFullName); - const requestParams = { owner, repo, ref }; + const requestParams = { owner, repo, ref, per_page: options?.pageSize, page: options?.page }; const { data } = await fetcher.request('GET /repos/{owner}/{repo}/commits/{ref}', requestParams); return ( data.files?.map((item) => ({ diff --git a/extensions/github1s/src/adapters/github1s/index.ts b/extensions/github1s/src/adapters/github1s/index.ts index 7451ae337..d0bb93ed0 100644 --- a/extensions/github1s/src/adapters/github1s/index.ts +++ b/extensions/github1s/src/adapters/github1s/index.ts @@ -29,10 +29,6 @@ export class GitHub1sAdapter implements Adapter { activateAsDefault() { // register settings view and show it in activity bar setVSCodeContext('github1s:views:settings:visible', true); - setVSCodeContext('github1s:views:codeReviewList:visible', true); - setVSCodeContext('github1s:views:commitList:visible', true); - setVSCodeContext('github1s:views:fileHistory:visible', true); - setVSCodeContext('github1s:features:gutterBlame:enabled', true); vscode.window.registerWebviewViewProvider( GitHub1sSettingsViewProvider.viewType, @@ -52,9 +48,5 @@ export class GitHub1sAdapter implements Adapter { deactivateAsDefault() { setVSCodeContext('github1s:views:settings:visible', false); - setVSCodeContext('github1s:views:codeReviewList:visible', false); - setVSCodeContext('github1s:views:commitList:visible', false); - setVSCodeContext('github1s:views:fileHistory:visible', false); - setVSCodeContext('github1s:features:gutterBlame:enabled', false); } } diff --git a/extensions/github1s/src/adapters/gitlab1s/data-source.ts b/extensions/github1s/src/adapters/gitlab1s/data-source.ts index 9b79a2a95..665a7e0a5 100644 --- a/extensions/github1s/src/adapters/gitlab1s/data-source.ts +++ b/extensions/github1s/src/adapters/gitlab1s/data-source.ts @@ -251,7 +251,7 @@ export class GitLab1sDataSource extends DataSource { page: options?.page, per_page: options?.pageSize, sha: options?.from, - path: isString(options?.path) ? trimStart(options.path, '/') : undefined, + path: trimStart(options?.path || '', '/') || undefined, author: options?.author, }; const requestParams = { repo, ...queryParams }; @@ -296,10 +296,13 @@ export class GitLab1sDataSource extends DataSource { } @trySourcegraphApiFirst - async provideCommitChangedFiles(repo: string, ref: string, _options?: CommonQueryOptions): Promise { + async provideCommitChangedFiles(repo: string, ref: string, options?: CommonQueryOptions): Promise { const fetcher = GitLabFetcher.getInstance(); - const requestParams = { repo, ref }; - const { data } = await fetcher.request('GET /projects/{repo}/repository/commits/{ref}/diff', requestParams); + const requestParams = { repo, ref, per_page: options?.pageSize, page: options?.page }; + const { data } = await fetcher.request( + 'GET /projects/{repo}/repository/commits/{ref}/diff?per_page={per_page}&page={page}', + requestParams, + ); return ( data?.map((item) => ({ path: normalizePath(item.new_path || item.old_path!), diff --git a/extensions/github1s/src/adapters/gitlab1s/index.ts b/extensions/github1s/src/adapters/gitlab1s/index.ts index 5d6db81c0..bd80c1e02 100644 --- a/extensions/github1s/src/adapters/gitlab1s/index.ts +++ b/extensions/github1s/src/adapters/gitlab1s/index.ts @@ -29,10 +29,6 @@ export class GitLab1sAdapter implements Adapter { activateAsDefault() { // register settings view and show it in activity bar setVSCodeContext('github1s:views:settings:visible', true); - setVSCodeContext('github1s:views:codeReviewList:visible', true); - setVSCodeContext('github1s:views:commitList:visible', true); - setVSCodeContext('github1s:views:fileHistory:visible', true); - setVSCodeContext('github1s:features:gutterBlame:enabled', true); vscode.window.registerWebviewViewProvider( GitLab1sSettingsViewProvider.viewType, @@ -50,9 +46,5 @@ export class GitLab1sAdapter implements Adapter { deactivateAsDefault() { setVSCodeContext('github1s:views:settings:visible', false); - setVSCodeContext('github1s:views:codeReviewList:visible', false); - setVSCodeContext('github1s:views:commitList:visible', false); - setVSCodeContext('github1s:views:fileHistory:visible', false); - setVSCodeContext('github1s:features:gutterBlame:enabled', false); } } diff --git a/extensions/github1s/src/adapters/index.ts b/extensions/github1s/src/adapters/index.ts index db0ec4901..76abdf0ac 100644 --- a/extensions/github1s/src/adapters/index.ts +++ b/extensions/github1s/src/adapters/index.ts @@ -10,6 +10,7 @@ import { BitbucketAdapter } from './bitbucket1s'; import { Npmjs1sAdapter } from './npmjs1s'; import { DiscoveryAdapter } from './discovery'; import { Adapter, DataSource, PlatformName, RouterParser } from './types'; +import { setVSCodeContext } from '@/helpers/vscode'; const emptyAdapter = { scheme: 'empty', @@ -27,6 +28,8 @@ export const registerAdapters = async (): Promise => { adapterManager.registerAdapter(new Npmjs1sAdapter()), adapterManager.registerAdapter(new DiscoveryAdapter()), ]); + await setVSCodeContext('github1s:views:codeReviewList:visible', await supportsCodeReviewFeatures()); + await setVSCodeContext('github1s:features:gutterBlame:enabled', await supportsGutterBlameFeatures()); }; export const getAdapter = (scheme?: string): Adapter => { @@ -36,3 +39,31 @@ export const getAdapter = (scheme?: string): Adapter => { export const getAllAdapters = (): Adapter[] => { return adapterManager.getAllAdapters(); }; + +export const supportsDataSourceMethods = async ( + methods: (keyof DataSource)[], + scheme: string = getAdapter().scheme, +): Promise => { + const adapter = getAllAdapters().find((adapter) => adapter.scheme === scheme); + if (!adapter) { + return false; + } + const dataSource = await adapter.resolveDataSource(); + // Inherited default methods return empty results and do not indicate support. + return methods.every((method) => dataSource[method] !== DataSource.prototype[method]); +}; + +export const supportsCommitFeatures = (scheme?: string): Promise => { + return supportsDataSourceMethods(['provideCommits', 'provideCommit', 'provideCommitChangedFiles'], scheme); +}; + +export const supportsCodeReviewFeatures = (scheme?: string): Promise => { + return supportsDataSourceMethods( + ['provideCodeReviews', 'provideCodeReview', 'provideCodeReviewChangedFiles'], + scheme, + ); +}; + +export const supportsGutterBlameFeatures = (scheme?: string): Promise => { + return supportsDataSourceMethods(['provideFileBlameRanges'], scheme); +}; diff --git a/extensions/github1s/src/adapters/sourcegraph/comparison.ts b/extensions/github1s/src/adapters/sourcegraph/comparison.ts index dc7607ef1..d46adadae 100644 --- a/extensions/github1s/src/adapters/sourcegraph/comparison.ts +++ b/extensions/github1s/src/adapters/sourcegraph/comparison.ts @@ -8,10 +8,10 @@ import { ChangedFile, FileChangeStatus } from '../types'; import { querySourcegraphRepository } from './common'; const ComparisonQuery = gql` - query ($repository: String!, $base: String!, $head: String!) { + query ($repository: String!, $base: String!, $head: String!, $first: Int) { repository(name: $repository) { comparison(base: $base, head: $head) { - fileDiffs { + fileDiffs(first: $first) { nodes { newPath oldPath @@ -35,10 +35,15 @@ const getFileChangeStatus = (oldPath: string | null, newPath: string | null): Fi return FileChangeStatus.Modified; }; -export const compareCommits = async (repository: string, base: string, head: string): Promise => { +export const compareCommits = async ( + repository: string, + base: string, + head: string, + limit?: number, +): Promise => { const repositoryData = await querySourcegraphRepository({ query: ComparisonQuery, - variables: { repository, base, head }, + variables: { repository, base, head, first: limit }, }); const diffFiles = repositoryData.comparison?.fileDiffs?.nodes || []; diff --git a/extensions/github1s/src/adapters/sourcegraph/data-source.ts b/extensions/github1s/src/adapters/sourcegraph/data-source.ts index 1d42f437d..901c165f6 100644 --- a/extensions/github1s/src/adapters/sourcegraph/data-source.ts +++ b/extensions/github1s/src/adapters/sourcegraph/data-source.ts @@ -191,7 +191,7 @@ export class SourcegraphDataSource extends DataSource { let commits = await getCommits( this.buildRepository(repo), options?.from || 'HEAD', - options?.path === undefined ? undefined : trimStart(options.path, '/'), + trimStart(options?.path || '', '/') || undefined, options?.pageSize ? options.pageSize * (options.page || 1) : undefined, ); if (options?.path && options.path !== '/' && commits.length) { @@ -206,8 +206,15 @@ export class SourcegraphDataSource extends DataSource { return getCommit(this.buildRepository(repo), ref); } - async provideCommitChangedFiles(repo: string, ref: string, _options?: CommonQueryOptions): Promise { - return (await compareCommits(this.buildRepository(repo), `${ref}~`, ref)).map((file) => ({ + async provideCommitChangedFiles(repo: string, ref: string, options?: CommonQueryOptions): Promise { + const changedFiles = await compareCommits( + this.buildRepository(repo), + `${ref}~`, + ref, + options?.pageSize ? options.pageSize * (options.page || 1) : undefined, + ); + const files = options?.pageSize ? changedFiles.slice(options.pageSize * ((options.page || 1) - 1)) : changedFiles; + return files.map((file) => ({ ...file, path: normalizePath(file.path), previousPath: file.previousPath ? normalizePath(file.previousPath) : undefined, diff --git a/extensions/github1s/src/changes/files.ts b/extensions/github1s/src/changes/files.ts index 915bcee45..c20d45407 100644 --- a/extensions/github1s/src/changes/files.ts +++ b/extensions/github1s/src/changes/files.ts @@ -4,7 +4,6 @@ */ import * as vscode from 'vscode'; -import queryString from 'query-string'; import * as adapterTypes from '@/adapters/types'; import router from '@/router'; import { basename } from '@/helpers/util'; @@ -39,14 +38,17 @@ export const getCodeReviewChangedFiles = async ( }); }; -export const getCommitChangedFiles = async (commit: adapterTypes.Commit) => { - const repository = Repository.getCurrentInstance(); +export const getCommitChangedFiles = async ( + commit: adapterTypes.Commit, + repository = Repository.getCurrentInstance(), +) => { + const { scheme, repo } = repository; // if the commit.parents is more than one element // the parents[1].sha should be the merge source commitSha // so we use the parents[0].sha as the parent commitSha const parentCommitSha = commit?.parents?.[0] || ''; - const baseRootUri = router.buildUri({ ref: parentCommitSha }); - const headRootUri = router.buildUri({ ref: commit.sha }, baseRootUri); + const headRootUri = router.buildUri({ scheme, repo, ref: commit.sha }); + const baseRootUri = router.buildUri({ ref: parentCommitSha }, headRootUri); const changedFiles = await repository.getCommitChangedFiles(commit.sha); return changedFiles.map((commitFile) => { @@ -117,15 +119,10 @@ export const getChangedFileDiffCommand = (changedFile: VSCodeChangedFile): vscod } const title = getChangedFileDiffTitle(baseFileUri, headFileUri, status); - const query = queryString.stringify({ - status, - base: baseFileUri.with({ query: '' }).toString(), - head: headFileUri.with({ query: '' }).toString(), - }); return { title: 'Diff', command: 'vscode.diff', - arguments: [baseFileUri.with({ query }), headFileUri.with({ query }), title], + arguments: [baseFileUri, headFileUri, title], }; }; diff --git a/extensions/github1s/src/changes/history.ts b/extensions/github1s/src/changes/history.ts new file mode 100644 index 000000000..410b363b6 --- /dev/null +++ b/extensions/github1s/src/changes/history.ts @@ -0,0 +1,146 @@ +import * as vscode from 'vscode'; +import queryString from 'query-string'; +import { Commit, FileChangeStatus } from '@/adapters/types'; +import { Repository } from '@/repository'; +import router from '@/router'; +import { getCommitChangedFiles } from './files'; + +// Expose commit history for the router's current repository and ref in the SCM graph. +export class GitHub1sHistoryProvider implements vscode.SourceControlHistoryProvider, vscode.Disposable { + private static instance: GitHub1sHistoryProvider | null = null; + // The SCM graph requires a current ref to initialize, but no commit is marked as current. + currentHistoryItemRef: vscode.SourceControlHistoryItemRef | undefined; + readonly currentHistoryItemRemoteRef = undefined; + readonly currentHistoryItemBaseRef = undefined; + + private readonly currentRefsChanged = new vscode.EventEmitter(); + readonly onDidChangeCurrentHistoryItemRefs = this.currentRefsChanged.event; + private readonly refsChanged = new vscode.EventEmitter(); + readonly onDidChangeHistoryItemRefs = this.refsChanged.event; + + private constructor() {} + + public static getInstance(): GitHub1sHistoryProvider { + if (!GitHub1sHistoryProvider.instance) { + GitHub1sHistoryProvider.instance = new GitHub1sHistoryProvider(); + } + return GitHub1sHistoryProvider.instance; + } + + refresh() { + const { repo, ref } = router.getState(); + const previousRef = this.currentHistoryItemRef; + this.currentHistoryItemRef = repo ? { id: ref, name: ref } : undefined; + this.currentRefsChanged.fire(); + // Repository navigation can keep the same branch name (for example, "main"). + // Explicitly mark the ref as modified so the graph reloads even when its ID is unchanged. + if (previousRef?.id === this.currentHistoryItemRef?.id) { + this.refsChanged.fire({ + added: [], + removed: [], + modified: this.currentHistoryItemRef ? [this.currentHistoryItemRef] : [], + silent: false, + }); + } + } + + // Release event emitters and invalidate pending history requests. + dispose() { + this.currentHistoryItemRef = undefined; + this.currentRefsChanged.dispose(); + this.refsChanged.dispose(); + } + + // List refs for the SCM graph's reference picker and filters, + // optionally restricted by ID. Only the router's current ref is exposed. + provideHistoryItemRefs(ids: string[] | undefined): vscode.SourceControlHistoryItemRef[] { + const ref = this.currentHistoryItemRef; + return ref && (!ids || ids.includes(ref.id)) ? [ref] : []; + } + + // Return a page of commits for the SCM graph using the current router ref. + async provideHistoryItems(options: vscode.SourceControlHistoryOptions, token: vscode.CancellationToken) { + const currentRef = this.currentHistoryItemRef; + if (!currentRef || token.isCancellationRequested) { + return []; + } + + const commits = await this.getCommitPage(currentRef, options, token); + // Refresh replaces the ref object, so identity also detects navigation to the same ref name. + // Discard results from requests that no longer belong to the current history view. + if (!commits || token.isCancellationRequested || currentRef !== this.currentHistoryItemRef) { + return []; + } + + return commits.map((commit) => this.toHistoryItem(commit)); + } + + private async getCommitPage( + ref: vscode.SourceControlHistoryItemRef, + { skip = 0, limit = 50 }: vscode.SourceControlHistoryOptions, + token: vscode.CancellationToken, + ): Promise { + const repository = Repository.getCurrentInstance(); + let commits = await repository.getCommitList(ref.id); + if (token.isCancellationRequested || ref !== this.currentHistoryItemRef) { + return undefined; + } + + // Reuse cached history and load at most one more page when it has been consumed. + if (skip > 0 && skip >= commits.length) { + await repository.loadMoreCommits(ref.id); + commits = await repository.getCommitList(ref.id); + } + // Commit-ID limits are not resolved here; use the default page size for that form. + const count = typeof limit === 'number' ? Math.max(0, limit) : 50; + return commits.slice(skip, skip + count); + } + + // Resolve a single commit by ID, independently of the currently loaded history pages. + async resolveHistoryItem(id: string) { + const commit = await Repository.getCurrentInstance().getCommitItem(id); + return commit ? this.toHistoryItem(commit) : undefined; + } + + // Provide changed files and before/after URIs for a commit's diff view. + async provideHistoryItemChanges(id: string, parentId: string | undefined) { + const commit = await Repository.getCurrentInstance().getCommitItem(id); + if (!commit) { + return []; + } + // Changed files are computed against the first parent, including for merge commits. + // Reject other parents or ranges to avoid displaying a diff against the wrong base. + if (parentId !== commit.parents[0]) { + throw new Error("Only changes against a commit's first parent are supported."); + } + const files = await getCommitChangedFiles(commit); + return files.map((file) => ({ + // The decoration provider reads changeStatus from the resource URI. + uri: file.headFileUri.with({ query: queryString.stringify({ changeStatus: file.status }) }), + // Root commits and added files have no original content; removed files have no modified content. + originalUri: !parentId || file.status === FileChangeStatus.Added ? undefined : file.baseFileUri, + modifiedUri: file.status === FileChangeStatus.Removed ? undefined : file.headFileUri, + })); + } + + // Find the common ancestor of the requested refs for comparisons; unsupported here. + resolveHistoryItemRefsCommonAncestor = () => undefined; + // Provide commit context for chat requests; unsupported here. + resolveHistoryItemChatContext = () => undefined; + // Provide file-change context between two commits for chat requests; unsupported here. + resolveHistoryItemChangeRangeChatContext = () => undefined; + + private toHistoryItem(commit: Commit): vscode.SourceControlHistoryItem { + return { + id: commit.sha, + parentIds: commit.parents, + subject: commit.message.split('\n')[0], + message: commit.message, + displayId: commit.sha.slice(0, 7), + author: commit.author, + authorEmail: commit.email, + authorIcon: commit.avatarUrl ? vscode.Uri.parse(commit.avatarUrl) : undefined, + timestamp: commit.createTime?.getTime(), + }; + } +} diff --git a/extensions/github1s/src/changes/index.ts b/extensions/github1s/src/changes/index.ts index 4a3456f8b..aa0a27dad 100644 --- a/extensions/github1s/src/changes/index.ts +++ b/extensions/github1s/src/changes/index.ts @@ -5,26 +5,39 @@ import * as vscode from 'vscode'; import * as adapterTypes from '@/adapters/types'; +import { supportsCommitFeatures } from '@/adapters'; +import { getExtensionContext } from '@/helpers/context'; import { GitHub1sQuickDiffProvider } from './quick-diff'; import { getChangedFileDiffCommand, getChangedFiles } from './files'; +import { GitHub1sHistoryProvider } from './history'; -export const updateSourceControlChanges = (() => { - const sourceControl = vscode.scm.createSourceControl('github1s', 'GitHub1s'); - const changesGroup = sourceControl.createResourceGroup('changes', 'Changes'); - sourceControl.quickDiffProvider = new GitHub1sQuickDiffProvider(); +const sourceControl = vscode.scm.createSourceControl('github1s', 'GitHub1s'); +const changesGroup = sourceControl.createResourceGroup('changes', 'Changes'); +sourceControl.quickDiffProvider = new GitHub1sQuickDiffProvider(); - return async () => { - const changedFiles = await getChangedFiles(); +export const registerSourceControlHistory = async () => { + const context = getExtensionContext(); + context.subscriptions.push(sourceControl); + if (!(await supportsCommitFeatures())) { + return; + } + const historyProvider = GitHub1sHistoryProvider.getInstance(); + sourceControl.historyProvider = historyProvider; + context.subscriptions.push(historyProvider); + historyProvider.refresh(); +}; - changesGroup.resourceStates = changedFiles.map((changedFile) => { - return { - resourceUri: changedFile.headFileUri.with({ authority: '' }), - decorations: { - strikeThrough: changedFile.status === adapterTypes.FileChangeStatus.Removed, - tooltip: changedFile.status, - }, - command: getChangedFileDiffCommand(changedFile), - }; - }); - }; -})(); +export const updateSourceControlChanges = async () => { + const changedFiles = await getChangedFiles(); + + changesGroup.resourceStates = changedFiles.map((changedFile) => { + return { + resourceUri: changedFile.headFileUri.with({ authority: '' }), + decorations: { + strikeThrough: changedFile.status === adapterTypes.FileChangeStatus.Removed, + tooltip: changedFile.status, + }, + command: getChangedFileDiffCommand(changedFile), + }; + }); +}; diff --git a/extensions/github1s/src/commands/commit.ts b/extensions/github1s/src/commands/commit.ts index 97e3df402..6b6859ab4 100644 --- a/extensions/github1s/src/commands/commit.ts +++ b/extensions/github1s/src/commands/commit.ts @@ -7,11 +7,41 @@ import * as vscode from 'vscode'; import router from '@/router'; import { getAdapter } from '@/adapters'; import { Repository } from '@/repository'; -import { CommitTreeItem, getCommitTreeItemDescription } from '@/views/commit-list'; -import { commitTreeDataProvider, fileHistoryTreeDataProvider } from '@/views'; +import { getCommitDescription } from '@/helpers/commit'; +import { getChangedFileDiffCommand, getCommitChangedFiles } from '@/changes/files'; + +type CommitCommandArgument = string | vscode.TimelineItem | vscode.SourceControl; + +interface CommitContext { + scheme: string; + repo: string; + sha?: string; +} + +// SCM menus pass (sourceControl, historyItem); timeline menus pass (item, uri, source). +const resolveCommitContext = ( + item?: CommitCommandArgument, + historyItemOrUri?: vscode.SourceControlHistoryItem | vscode.Uri, +): CommitContext => { + if (historyItemOrUri && 'scheme' in historyItemOrUri) { + // for extensions/github1s/src/changes/history.ts + const { scheme, repo } = router.parseUri(historyItemOrUri); + return { scheme, repo, sha: typeof item === 'object' ? item.id : item }; + } + + let sha: string | undefined; + if (historyItemOrUri && 'id' in historyItemOrUri) { + // for extensions/github1s/src/providers/timeline.ts + sha = historyItemOrUri.id; + } else if (typeof item === 'string') { + sha = item; + } -export const checkCommitExists = async (repo: string, commitSha: string) => { - const dataSoruce = await getAdapter().resolveDataSource(); + return { scheme: getAdapter().scheme, repo: router.getState().repo, sha }; +}; + +const checkCommitExists = async (repo: string, commitSha: string, scheme?: string) => { + const dataSoruce = await getAdapter(scheme).resolveDataSource(); try { return !!(await dataSoruce.provideCommit(repo, commitSha)); } catch (error) { @@ -24,14 +54,12 @@ export const checkCommitExists = async (repo: string, commitSha: string) => { } }; -const commandSwitchToCommit = async (commitItemOrSha?: string | CommitTreeItem) => { - let commitSha: string | undefined = commitItemOrSha - ? typeof commitItemOrSha === 'string' - ? commitItemOrSha - : commitItemOrSha.commit.sha - : ''; - const { repo } = router.getState(); - const repository = Repository.getCurrentInstance(); +const commandSwitchToCommit = async ( + commitItemOrSha?: CommitCommandArgument, + historyItemOrUri?: vscode.SourceControlHistoryItem | vscode.Uri, +) => { + const { scheme, repo, sha } = resolveCommitContext(commitItemOrSha, historyItemOrUri); + let commitSha = sha; // if the a commitSha isn't provided, use quickInput if (!commitSha) { @@ -41,22 +69,18 @@ const commandSwitchToCommit = async (commitItemOrSha?: string | CommitTreeItem) alwaysShow: true, }; // use the commit list as the candidates + const repository = Repository.getInstance(scheme, repo); const commits = await repository.getCommitList(); const commitItems: vscode.QuickPickItem[] = commits.map((commit) => ({ commitSha: commit.sha, label: commit.message, - description: getCommitTreeItemDescription(commit), + description: getCommitDescription(commit), })); - const quickPick = vscode.window.createQuickPick(); - quickPick.matchOnDescription = true; - quickPick.items = [inputCommitShaItem, ...commitItems]; - quickPick.show(); - - const choice = (await new Promise((resolve) => - quickPick.onDidAccept(() => resolve(quickPick.activeItems[0])), - )) as vscode.QuickPickItem & { commitSha?: string }; - quickPick.hide(); + const choice = await vscode.window.showQuickPick( + [inputCommitShaItem, ...commitItems], + { matchOnDescription: true }, + ); // select nothing if (!choice) { @@ -74,85 +98,56 @@ const commandSwitchToCommit = async (commitItemOrSha?: string | CommitTreeItem) } } - const routerParser = router.getParser(); - if (await checkCommitExists(repo, commitSha!)) { - router.replace(await routerParser.buildCommitPath(repo, commitSha!)); - } -}; - -const commandDiffCommitFile = async (commitItem: CommitTreeItem) => { - const commitSha = commitItem.commit.sha; if (!commitSha) { return; } - const activeDocumentUri = vscode.window.activeTextEditor?.document?.uri; - if (!activeDocumentUri) { - return; - } - const fileUri = router.buildUri({ ref: commitSha }, activeDocumentUri).with({ query: '' }); - return vscode.commands.executeCommand('github1s.commands.openFilePreviousRevision', fileUri); -}; -// this command is used in `source control commit list view` -const commandOpenCommitOnOfficialPage = async (commitItemOrSha?: string | CommitTreeItem) => { - const commitSha = commitItemOrSha - ? typeof commitItemOrSha === 'string' - ? commitItemOrSha - : commitItemOrSha.commit.sha - : ''; - if (commitSha) { - const { repo } = router.getState(); - const routerParser = router.getParser(); - const commitPath = await routerParser.buildCommitPath(repo, commitSha); - const commitLink = await routerParser.buildExternalLink(commitPath); - return vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(commitLink)); + if (await checkCommitExists(repo, commitSha, scheme)) { + const routerParser = await getAdapter(scheme).resolveRouterParser(); + router.replace(await routerParser.buildCommitPath(repo, commitSha)); } }; -const commandRefreshCommitList = (forceUpdate = true) => { - return commitTreeDataProvider.updateTree(forceUpdate); -}; - -const commandLoadMoreCommits = async () => { - return commitTreeDataProvider.loadMoreCommits(); -}; - -const commandLoadMoreCommitChangedFiles = async (commitSha: string) => { - return commitTreeDataProvider.loadMoreChangedFiles(commitSha); -}; - -const commandRefreshFileHistoryCommitList = (forceUpdate = true) => { - return fileHistoryTreeDataProvider.updateTree(forceUpdate); -}; +const commandOpenCommitOnOfficialPage = async ( + commitItemOrSha?: CommitCommandArgument, + historyItemOrUri?: vscode.SourceControlHistoryItem | vscode.Uri, +) => { + const { scheme, repo, sha } = resolveCommitContext(commitItemOrSha, historyItemOrUri); + if (!sha) { + return; + } -const commandLoadMoreFileHistoryCommits = async () => { - return fileHistoryTreeDataProvider.loadMoreCommits(); + const routerParser = await getAdapter(scheme).resolveRouterParser(); + const commitPath = await routerParser.buildCommitPath(repo, sha); + const commitLink = await routerParser.buildExternalLink(commitPath); + return vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(commitLink)); }; -const commandLoadMoreFileHistoryCommitChangedFiles = async (commitSha: string) => { - return fileHistoryTreeDataProvider.loadMoreChangedFiles(commitSha); +const commandDiffCommitFile = async (uri: vscode.Uri) => { + const { scheme, repo, ref, path } = router.parseUri(uri); + const repository = Repository.getInstance(scheme, repo); + const commit = await repository.getCommitItem(ref); + if (!commit) { + throw new Error(`Commit not found: ${ref}`); + } + const files = await getCommitChangedFiles(commit, repository); + const file = + files.find((file) => file.headFileUri.path === path) || files.find((file) => file.baseFileUri.path === path); + if (!file) { + throw new Error(`No changes found for ${path} in ${commit.sha}`); + } + const command = getChangedFileDiffCommand(file); + return vscode.commands.executeCommand(command.command, ...(command.arguments || [])); }; export const registerCommitCommands = (context: vscode.ExtensionContext) => { return context.subscriptions.push( - vscode.commands.registerCommand('github1s.commands.refreshCommitList', commandRefreshCommitList), + vscode.commands.registerCommand('github1s.commands.diffCommitFile', commandDiffCommitFile), vscode.commands.registerCommand('github1s.commands.searchCommit', commandSwitchToCommit), vscode.commands.registerCommand('github1s.commands.switchToCommit', commandSwitchToCommit), - vscode.commands.registerCommand('github1s.commands.diffCommitFile', commandDiffCommitFile), vscode.commands.registerCommand('github1s.commands.openCommitOnGitHub', commandOpenCommitOnOfficialPage), vscode.commands.registerCommand('github1s.commands.openCommitOnGitLab', commandOpenCommitOnOfficialPage), vscode.commands.registerCommand('github1s.commands.openCommitOnBitbucket', commandOpenCommitOnOfficialPage), vscode.commands.registerCommand('github1s.commands.openCommitOnOfficialPage', commandOpenCommitOnOfficialPage), - vscode.commands.registerCommand('github1s.commands.loadMoreCommits', commandLoadMoreCommits), - vscode.commands.registerCommand('github1s.commands.loadMoreCommitChangedFiles', commandLoadMoreCommitChangedFiles), - vscode.commands.registerCommand('github1s.commands.loadMoreFileHistoryCommits', commandLoadMoreFileHistoryCommits), - vscode.commands.registerCommand( - 'github1s.commands.loadMoreFileHistoryCommitChangedFiles', - commandLoadMoreFileHistoryCommitChangedFiles, - ), - vscode.commands.registerCommand( - 'github1s.commands.refreshFileHistoryCommitList', - commandRefreshFileHistoryCommitList, - ), ); }; diff --git a/extensions/github1s/src/commands/editor.ts b/extensions/github1s/src/commands/editor.ts index 4d936cb01..b7af15895 100644 --- a/extensions/github1s/src/commands/editor.ts +++ b/extensions/github1s/src/commands/editor.ts @@ -7,9 +7,11 @@ import * as vscode from 'vscode'; import queryString from 'query-string'; import router from '@/router'; import { emptyFileUri } from '@/providers'; +import { supportsCommitFeatures } from '@/adapters'; import { FileChangeStatus } from '@/adapters/types'; import { Repository } from '@/repository'; import { getChangedFiles, getChangedFileDiffCommand, getChangedFileDiffTitle } from '@/changes/files'; +import { omit } from '@/helpers/util'; export const getChangedFileFromSourceControl = async (fileUri: vscode.Uri) => { // the file should belong to current workspace @@ -35,103 +37,138 @@ const commandDiffChangedFile = async (fileUri: vscode.Uri) => { vscode.commands.executeCommand(command.command, ...(command.arguments || [])); }; -const openFileToEditor = async (fileUri) => { - return vscode.commands.executeCommand('vscode.open', fileUri, { preview: false }); +const isRepositoryFileUri = async (uri: vscode.Uri | undefined): Promise => { + return !!uri && supportsCommitFeatures(uri.scheme); }; -// open the left file in the diff editor title -const commandDiffViewOpenLeftFile = async (fileUri: vscode.Uri) => { - const query = queryString.parse(fileUri?.query || ''); - return query.base ? openFileToEditor(vscode.Uri.parse(query.base as string)) : null; -}; - -// open the right file in the diff editor title -const commandDiffViewOpenRightFile = async (fileUri: vscode.Uri) => { - const query = queryString.parse(fileUri?.query || ''); - return query.head ? openFileToEditor(vscode.Uri.parse(query.head as string)) : null; -}; - -// get the file uri with the concrete commit sha, the `ref` in -// `fileUri.authority` maybe newer but not related this file -const getConcreteFileUri = async (fileUri: vscode.Uri) => { - const { scheme, repo, ref, path } = router.parseUri(fileUri); - const repository = Repository.getInstance(scheme, repo); - const commit = await repository.getFileLatestCommit(ref, path); - const latestCommitSha = commit?.sha || (await repository.getCommitItem(ref))?.sha; +const getActiveDiffInput = (resource?: vscode.Uri): vscode.TabInputTextDiff | undefined => { + const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input; + if (!(input instanceof vscode.TabInputTextDiff)) { + return; + } - return router.buildUri({ ref: latestCommitSha }, fileUri); + // Title actions receive the modified URI. Ignore actions targeting another diff. + if (resource && resource.toString() !== input.modified.toString()) { + return; + } + return input; }; -// show the file's diff between current commit and previous commit -const commandOpenFilePreviousRevision = async (fileUri: vscode.Uri) => { - const queryBaseUriStr = queryString.parse(fileUri.query).base; - const rightFileUri = await getConcreteFileUri( - // if the `queryBaseUriStr` is empty, which means this command is called from - // a normal file editor (not a diff editor), just use `fileUri` in this case - queryBaseUriStr ? vscode.Uri.parse(queryBaseUriStr as string) : fileUri, - ); - const { scheme, repo, ref: rightCommitSha } = router.parseUri(rightFileUri); - const repository = Repository.getInstance(scheme, repo); - const leftCommit = await repository.getPreviousCommit(rightCommitSha, rightFileUri.path); - // if we can't find previous commit, use the `emptyFileUri` as the leftFileUri - const leftFileUri = leftCommit ? router.buildUri({ ref: leftCommit.sha }, rightFileUri) : emptyFileUri; - - const changedStatus = leftCommit ? FileChangeStatus.Modified : FileChangeStatus.Added; - const hasNextRevision = !!(await repository.getNextCommit(rightCommitSha, rightFileUri.path)); - - const query = queryString.stringify({ - base: leftFileUri.with({ query: '' }).toString(), - head: rightFileUri.with({ query: '' }).toString(), - status: changedStatus, - // if we can't find a newer commit for this file, - // the `Show Next Commit` Button would be disabled. - hasNextRevision, - }); - - return vscode.commands.executeCommand( - 'vscode.diff', - leftFileUri.with({ query }), - rightFileUri.with({ query }), - getChangedFileDiffTitle(leftFileUri, rightFileUri, changedStatus), - ); +const createCommandDiffViewOpenFile = (side: 'original' | 'modified') => async (resource?: vscode.Uri) => { + const fileUri = getActiveDiffInput(resource)?.[side]; + if (fileUri && fileUri?.scheme !== emptyFileUri.scheme) { + await vscode.commands.executeCommand('workbench.action.keepEditor'); + return vscode.commands.executeCommand('vscode.open', fileUri, {}); + } }; -// show the file's diff between current commit and next commit -const commandOpenFileNextRevision = async (fileUri: vscode.Uri) => { - const leftFileUri = await getConcreteFileUri(fileUri); - - const { scheme, repo, ref: leftCommitSha } = router.parseUri(leftFileUri); - const repository = Repository.getInstance(scheme, repo); - const rightCommit = await repository.getNextCommit(leftCommitSha, leftFileUri.path); +const resolveOpenFileRevisionArgs = async ( + fileUri: vscode.Uri | undefined, + direction: 'previous' | 'next', +): Promise<[vscode.Uri, string]> => { + let baseUri: vscode.Uri | undefined, from: string | undefined; + const getQueryFrom = (uri: vscode.Uri): string | undefined => { + return queryString.parse(uri.query).from as string | undefined; + }; + + const textDiffInput = getActiveDiffInput(fileUri); + if (textDiffInput) { + // this is a diff editor + const { original, modified } = textDiffInput; + const [hasLeftFile, hasRightFile] = await Promise.all([ + isRepositoryFileUri(original), + isRepositoryFileUri(modified), + ]); + + if (direction === 'previous' && hasLeftFile) { + baseUri = original; + } + if (direction === 'next' && hasRightFile) { + baseUri = modified; + } + if (hasRightFile) { + from = getQueryFrom(modified); + } + } else if (fileUri && (await isRepositoryFileUri(fileUri))) { + // this is a single file editor + from = getQueryFrom(fileUri); + baseUri = fileUri; + } - if (!rightCommit) { - return vscode.window.showInformationMessage('There is no next commit found.'); + if (!baseUri) { + throw new Error('Unable to resolve the target file.'); } - const rightFileUri = router.buildUri({ ref: rightCommit.sha }, leftFileUri); - const hasNextRevision = !!(await repository.getNextCommit(rightCommit.sha, rightFileUri.path)); + if (!from) { + // If 'from' cannot be obtained in the query, use the ref of baseUri as 'from' + const { scheme, repo, ref, path } = router.parseUri(baseUri); + const repository = Repository.getInstance(scheme, repo); + from = (await repository.getFileLatestCommit(ref, path))?.sha; + if (!from) { + throw new Error('Unable to resolve the latest commit for this file.'); + } + baseUri = router.buildUri({ ref: from }, baseUri); + } - const query = queryString.stringify({ - base: leftFileUri.with({ query: '' }).toString(), - head: rightFileUri.with({ query: '' }).toString(), - status: FileChangeStatus.Modified, - hasNextRevision, - }); + return [baseUri, from]; +}; - return vscode.commands.executeCommand( - 'vscode.diff', - leftFileUri.with({ query }), - rightFileUri.with({ query }), - getChangedFileDiffTitle(leftFileUri, rightFileUri, FileChangeStatus.Modified), - ); +const createCommandOpenFileRevision = (direction: 'previous' | 'next') => async (fileUri?: vscode.Uri) => { + try { + const [baseUri, from] = await resolveOpenFileRevisionArgs(fileUri, direction); + const { scheme, repo, ref, path } = router.parseUri(baseUri); + const repository = Repository.getInstance(scheme, repo); + const baseSha = (await repository.getCommitItem(ref))?.sha; + if (!baseSha) { + throw new Error('Unable to resolve the commit for this file.'); + } + + let leftFileUri: vscode.Uri | undefined, rightFileUri: vscode.Uri | undefined; + if (direction === 'previous') { + const prevCommit = await repository.getPreviousCommit(baseSha, path, from); + leftFileUri = prevCommit ? router.buildUri({ ref: prevCommit.sha }, baseUri) : emptyFileUri; + rightFileUri = baseUri; + } else { + const nextCommit = await repository.getNextCommit(baseSha, path, from); + if (!nextCommit) throw new Error('Unable to find next commit for this file.'); + leftFileUri = baseUri; + rightFileUri = router.buildUri({ ref: nextCommit.sha }, baseUri); + } + + const hasNext = router.parseUri(rightFileUri).ref !== from || undefined; + const leftQuery = queryString.stringify(omit(queryString.parse(baseUri.query), ['from'])); + const rightQuery = queryString.stringify({ ...queryString.parse(baseUri.query), from, hasNext }); + + if (fileUri && !queryString.parse(fileUri.query).from) { + await vscode.commands.executeCommand('workbench.action.keepEditor'); + } + + return await vscode.commands.executeCommand( + 'vscode.diff', + leftFileUri.with({ query: leftQuery }), + rightFileUri.with({ query: rightQuery }), + getChangedFileDiffTitle(leftFileUri, rightFileUri, FileChangeStatus.Modified), + ); + } catch (error) { + return vscode.window.showErrorMessage(`Unable to open file revision: ${error.message}`); + } }; export const registerEditorCommands = (context: vscode.ExtensionContext) => { return context.subscriptions.push( vscode.commands.registerCommand('github1s.commands.diffChangedFile', commandDiffChangedFile), - vscode.commands.registerCommand('github1s.commands.diffViewOpenLeftFile', commandDiffViewOpenLeftFile), - vscode.commands.registerCommand('github1s.commands.diffViewOpenRightFile', commandDiffViewOpenRightFile), - vscode.commands.registerCommand('github1s.commands.openFilePreviousRevision', commandOpenFilePreviousRevision), - vscode.commands.registerCommand('github1s.commands.openFileNextRevision', commandOpenFileNextRevision), + vscode.commands.registerCommand( + 'github1s.commands.diffViewOpenLeftFile', + createCommandDiffViewOpenFile('original'), + ), + vscode.commands.registerCommand( + 'github1s.commands.diffViewOpenRightFile', + createCommandDiffViewOpenFile('modified'), + ), + vscode.commands.registerCommand( + 'github1s.commands.openFilePreviousRevision', + createCommandOpenFileRevision('previous'), + ), + vscode.commands.registerCommand('github1s.commands.openFileNextRevision', createCommandOpenFileRevision('next')), ); }; diff --git a/extensions/github1s/src/extension.ts b/extensions/github1s/src/extension.ts index 7be17e063..c4f2ad020 100644 --- a/extensions/github1s/src/extension.ts +++ b/extensions/github1s/src/extension.ts @@ -12,7 +12,7 @@ import { decorateStatusBar } from '@/statusbar'; import { registerEventListeners } from '@/listeners'; import { registerVSCodeProviders } from '@/providers'; import { registerGitHub1sCommands } from '@/commands'; -import { updateSourceControlChanges } from '@/changes'; +import { registerSourceControlHistory, updateSourceControlChanges } from '@/changes'; import { addRecentRepositories, setExtensionContext } from '@/helpers/context'; const browserUrlManager = { @@ -40,6 +40,7 @@ export async function activate(context: vscode.ExtensionContext) { registerEventListeners(), registerGitHub1sCommands(), registerCustomViews(), + registerSourceControlHistory(), updateSourceControlChanges(), decorateStatusBar(), ]); @@ -65,7 +66,7 @@ const initialVSCodeState = async () => { } else if (routerState.pageType === PageType.CodeReviewList) { vscode.commands.executeCommand('github1s.views.codeReviewList.focus'); } else if (routerState.pageType === PageType.CommitList) { - vscode.commands.executeCommand('github1s.views.commitList.focus'); + vscode.commands.executeCommand('workbench.scm.history.focus'); } else if ([PageType.CodeReview, PageType.Commit].includes(routerState.pageType)) { vscode.commands.executeCommand('workbench.scm.focus'); } else if (routerState.pageType === PageType.Search) { diff --git a/extensions/github1s/src/helpers/commit.ts b/extensions/github1s/src/helpers/commit.ts new file mode 100644 index 000000000..7288b2898 --- /dev/null +++ b/extensions/github1s/src/helpers/commit.ts @@ -0,0 +1,19 @@ +/** + * @file Commit display helpers + */ + +import type { Commit } from '@/adapters/types'; +import { relativeTimeTo, toISOString } from './date'; + +export const getCommitDescription = (commit: Commit): string => { + const shortCommitSha = commit.sha.slice(0, 7); + const relativeTimeStr = commit.createTime ? relativeTimeTo(commit.createTime) : null; + return [shortCommitSha, commit.author, relativeTimeStr].filter(Boolean).join(', '); +}; + +export const getCommitTooltip = (commit: Commit): string => { + const shortCommitSha = commit.sha.slice(0, 7); + const ISOTimeStr = commit.createTime ? toISOString(commit.createTime) : null; + const detailText = [shortCommitSha, commit.author, ISOTimeStr].filter(Boolean).join(', '); + return `${commit.message}\n(${detailText})`; +}; diff --git a/extensions/github1s/src/helpers/util.ts b/extensions/github1s/src/helpers/util.ts index bf491fca9..675d7b4e7 100644 --- a/extensions/github1s/src/helpers/util.ts +++ b/extensions/github1s/src/helpers/util.ts @@ -65,3 +65,11 @@ export const prop = (obj: object, path: (string | number)[] = []): any => { export const last = (array: readonly T[]): T => { return array[array.length - 1]; }; + +export const omit = (obj: T, keys: K[]): Omit => { + const result = { ...obj }; + for (const key of keys) { + delete result[key]; + } + return result as Omit; +}; diff --git a/extensions/github1s/src/listeners/router/changes.ts b/extensions/github1s/src/listeners/router/changes.ts index 2fad3c2fd..75778ff40 100644 --- a/extensions/github1s/src/listeners/router/changes.ts +++ b/extensions/github1s/src/listeners/router/changes.ts @@ -6,14 +6,12 @@ import { RouterState } from '@/adapters/types'; import { updateCheckoutTo } from '@/statusbar/checkout'; import { updateSourceControlChanges } from '@/changes'; -import { commitTreeDataProvider } from '@/views'; type NewType = RouterState; export const sourceControlRouterListener = (currentState: NewType, previousState: RouterState) => { if (currentState.ref !== previousState.ref) { updateCheckoutTo(); - commitTreeDataProvider.updateTree(); } if ((currentState as any).codeReviewId !== (previousState as any).codeReviewId) { diff --git a/extensions/github1s/src/listeners/router/history.ts b/extensions/github1s/src/listeners/router/history.ts new file mode 100644 index 000000000..4e86782dc --- /dev/null +++ b/extensions/github1s/src/listeners/router/history.ts @@ -0,0 +1,12 @@ +/** + * @file Router listener for the source control history + */ + +import type { RouterState } from '@/adapters/types'; +import { GitHub1sHistoryProvider } from '@/changes/history'; + +export const historyRouterListener = (currentState: RouterState, previousState: RouterState) => { + if (currentState.repo !== previousState.repo || currentState.ref !== previousState.ref) { + GitHub1sHistoryProvider.getInstance().refresh(); + } +}; diff --git a/extensions/github1s/src/listeners/router/index.ts b/extensions/github1s/src/listeners/router/index.ts index bd2d28f6a..ccdeff80f 100644 --- a/extensions/github1s/src/listeners/router/index.ts +++ b/extensions/github1s/src/listeners/router/index.ts @@ -3,11 +3,23 @@ * @author netcon */ +import * as vscode from 'vscode'; import router from '@/router'; +import { getExtensionContext } from '@/helpers/context'; import { explorerRouterListener } from './explorer'; import { sourceControlRouterListener } from './changes'; +import { historyRouterListener } from './history'; +import { timelineRouterListener } from './timeline'; export const registerRouterEventListeners = () => { - router.addListener(explorerRouterListener); - router.addListener(sourceControlRouterListener); + const listeners = [ + explorerRouterListener, + sourceControlRouterListener, + historyRouterListener, + timelineRouterListener, + ]; + + for (const listener of listeners) { + getExtensionContext().subscriptions.push(new vscode.Disposable(router.addListener(listener))); + } }; diff --git a/extensions/github1s/src/listeners/router/timeline.ts b/extensions/github1s/src/listeners/router/timeline.ts new file mode 100644 index 000000000..22f7ca457 --- /dev/null +++ b/extensions/github1s/src/listeners/router/timeline.ts @@ -0,0 +1,12 @@ +/** + * @file Router listener for the file history timeline + */ + +import type { RouterState } from '@/adapters/types'; +import { FileHistoryTimelineProvider } from '@/providers/timeline'; + +export const timelineRouterListener = (currentState: RouterState, previousState: RouterState) => { + if (currentState.repo !== previousState.repo || currentState.ref !== previousState.ref) { + FileHistoryTimelineProvider.getInstance().refresh(); + } +}; diff --git a/extensions/github1s/src/listeners/vscode.ts b/extensions/github1s/src/listeners/vscode.ts index 9fbb311bf..bd1708f5c 100644 --- a/extensions/github1s/src/listeners/vscode.ts +++ b/extensions/github1s/src/listeners/vscode.ts @@ -70,17 +70,11 @@ const handleRouterOnTextEditorSelectionChange = async (editor: vscode.TextEditor browserPath !== router.getPath() && router.replace(browserPath); }; -// refresh file history view if active editor changed -const handleRefreshFileHistoryView = () => { - vscode.commands.executeCommand('github1s.commands.refreshFileHistoryCommitList', false); -}; - export const registerVSCodeEventListeners = () => { vscode.window.onDidChangeActiveTextEditor((editor) => { handleRouterOnActiveEditorChange(editor); handleOpenChangesContextOnActiveEditorChange(editor); handlegutterBlameOpenContextOnActiveEditorChange(); - handleRefreshFileHistoryView(); }); // debounce to update the browser url diff --git a/extensions/github1s/src/providers/decorations/changed-file.ts b/extensions/github1s/src/providers/decorations/changed-file.ts index b891bb063..4bf3b4de2 100644 --- a/extensions/github1s/src/providers/decorations/changed-file.ts +++ b/extensions/github1s/src/providers/decorations/changed-file.ts @@ -104,6 +104,10 @@ export class GitHub1sChangedFileDecorationProvider implements FileDecorationProv if (uri.scheme !== getAdapter().scheme) { return null; } + // Explicit revision statuses are decorated by the source-control provider. + if (uri.query.includes('changeStatus')) { + return null; + } const routerState = router.getState(); if (routerState.pageType === PageType.CodeReview) { diff --git a/extensions/github1s/src/providers/decorations/source-control.ts b/extensions/github1s/src/providers/decorations/source-control.ts index 3f24f7623..9b52e1b43 100644 --- a/extensions/github1s/src/providers/decorations/source-control.ts +++ b/extensions/github1s/src/providers/decorations/source-control.ts @@ -25,7 +25,6 @@ const selectedViewItemDecoration: FileDecoration = { export class GitHub1sSourceControlDecorationProvider implements FileDecorationProvider, Disposable { public static codeReviewSchema: string = 'github1s-source-control-code-review'; - public static commitSchema: string = 'github1s-source-control-commit'; private static instance: GitHub1sSourceControlDecorationProvider | null = null; private readonly disposable: Disposable; @@ -60,11 +59,5 @@ export class GitHub1sSourceControlDecorationProvider implements FileDecorationPr const query = queryString.parse(uri.query); return +(routerState as any).codeReviewId === +query.id! ? selectedViewItemDecoration : null; } - - if (uri.scheme === GitHub1sSourceControlDecorationProvider.commitSchema) { - const routerState = router.getState(); - const query = queryString.parse(uri.query); - return (routerState as any).commitSha === query.sha ? selectedViewItemDecoration : null; - } } } diff --git a/extensions/github1s/src/providers/index.ts b/extensions/github1s/src/providers/index.ts index 331d8f521..a77755984 100644 --- a/extensions/github1s/src/providers/index.ts +++ b/extensions/github1s/src/providers/index.ts @@ -4,7 +4,7 @@ */ import * as vscode from 'vscode'; -import { getAllAdapters } from '@/adapters'; +import { getAllAdapters, supportsCommitFeatures } from '@/adapters'; import { getExtensionContext } from '@/helpers/context'; import { GitHub1sFileSystemProvider } from './file-system'; import { GitHub1sFileSearchProvider } from './file-search'; @@ -15,13 +15,14 @@ import { GitHub1sSourceControlDecorationProvider } from './decorations/source-co import { GitHub1sDefinitionProvider } from './definition'; import { GitHub1sReferenceProvider } from './reference'; import { GitHub1sHoverProvider } from './hover'; -import router from '@/router'; +import { FileHistoryTimelineProvider } from './timeline'; export const EMPTY_FILE_SCHEME = 'github1s-empty-file'; export const emptyFileUri = vscode.Uri.from({ scheme: EMPTY_FILE_SCHEME }); -export const registerVSCodeProviders = () => { +export const registerVSCodeProviders = async () => { const context = getExtensionContext(); + const fileHistoryProvider = FileHistoryTimelineProvider.getInstance(); const allSchemes = getAllAdapters().map((item) => item.scheme); allSchemes.forEach((scheme) => { @@ -38,7 +39,12 @@ export const registerVSCodeProviders = () => { ); }); + const timelineSupport = await Promise.all(allSchemes.map(supportsCommitFeatures)); + const timelineSchemes = allSchemes.filter((_, index) => timelineSupport[index]); + context.subscriptions.push( + fileHistoryProvider, + vscode.workspace.registerTimelineProvider(timelineSchemes, fileHistoryProvider), vscode.window.registerFileDecorationProvider(GitHub1sSubmoduleDecorationProvider.getInstance()), vscode.window.registerFileDecorationProvider(GitHub1sChangedFileDecorationProvider.getInstance()), vscode.window.registerFileDecorationProvider(GitHub1sSourceControlDecorationProvider.getInstance()), diff --git a/extensions/github1s/src/providers/timeline.ts b/extensions/github1s/src/providers/timeline.ts new file mode 100644 index 000000000..260278af2 --- /dev/null +++ b/extensions/github1s/src/providers/timeline.ts @@ -0,0 +1,128 @@ +/** + * @file File history timeline + */ + +import * as vscode from 'vscode'; +import router, { UriState } from '@/router'; +import { Repository } from '@/repository'; +import { getCommitTooltip } from '@/helpers/commit'; +import { Commit } from '@/adapters/types'; +import { supportsCommitFeatures } from '@/adapters'; + +export class FileHistoryTimelineProvider implements vscode.TimelineProvider, vscode.Disposable { + private static instance: FileHistoryTimelineProvider | null = null; + readonly id = 'github1s.fileHistory'; + readonly label = 'Git History'; + + private readonly changed = new vscode.EventEmitter(); + readonly onDidChange = this.changed.event; + // Incremented on refresh so requests started earlier cannot publish stale results. + private refreshVersion = 0; + + private constructor() {} + + public static getInstance(): FileHistoryTimelineProvider { + if (!FileHistoryTimelineProvider.instance) { + FileHistoryTimelineProvider.instance = new FileHistoryTimelineProvider(); + } + return FileHistoryTimelineProvider.instance; + } + + refresh() { + this.refreshVersion++; + this.changed.fire(undefined); + } + + dispose() { + this.refreshVersion++; + this.changed.dispose(); + } + + async provideTimeline( + uri: vscode.Uri, + options: vscode.TimelineOptions, + token: vscode.CancellationToken, + ): Promise { + if (uri.path === '/' || token.isCancellationRequested || !(await supportsCommitFeatures(uri.scheme))) { + return undefined; + } + + const { scheme, repo, ref: fileRef, path } = router.parseUri(uri); + if (!repo) { + return undefined; + } + const state = router.getState(); + // Keep historical editors anchored to the workspace ref. + const ref = repo === state.repo ? state.ref : fileRef; + const version = this.refreshVersion; + const page = await this.getCommitPage({ scheme, repo, ref, path }, options, token); + if (!page || token.isCancellationRequested || version !== this.refreshVersion) { + return undefined; + } + + const fileUri = router.buildUri({ repo, ref }, uri).with({ query: '', fragment: '' }); + return { + items: page.commits.map((commit) => this.toTimelineItem(commit, fileUri)), + paging: { cursor: page.cursor }, + }; + } + + private async getCommitPage( + { scheme, repo, ref, path }: UriState, + { cursor, limit }: vscode.TimelineOptions, + token: vscode.CancellationToken, + ): Promise<{ commits: Commit[]; cursor?: string } | undefined> { + const repository = Repository.getInstance(scheme, repo); + const version = this.refreshVersion; + let commits = await repository.getCommitList(ref, path); + if (token.isCancellationRequested || version !== this.refreshVersion) { + return undefined; + } + + // Reuse cached history and load at most one more page when its last commit is reached. + if (cursor && cursor === commits[commits.length - 1]?.sha) { + await repository.loadMoreCommits(ref, path); + commits = await repository.getCommitList(ref, path); + } + + const cursorIndex = cursor ? commits.findIndex((commit) => commit.sha === cursor) : -1; + if (cursor && cursorIndex === -1) { + return { commits: [] }; + } + let page = commits.slice(cursorIndex + 1); + if (typeof limit === 'number') { + page = page.slice(0, Math.max(0, limit)); + } else if (limit) { + // Include the ID boundary; exclude commits older than the timestamp boundary. + const boundaryIndex = page.findIndex((commit) => + limit.id ? commit.sha === limit.id : (commit.createTime?.getTime() ?? 0) < limit.timestamp, + ); + if (boundaryIndex !== -1) { + page = page.slice(0, boundaryIndex + (limit.id ? 1 : 0)); + } + } + + const lastCommit = page[page.length - 1]; + const hasMore = + lastCommit !== undefined && + (lastCommit.sha !== commits[commits.length - 1]?.sha || (await repository.hasMoreCommits(ref, path))); + return { commits: page, cursor: hasMore ? lastCommit.sha : undefined }; + } + + private toTimelineItem(commit: Commit, uri: vscode.Uri): vscode.TimelineItem { + return { + id: commit.sha, + label: commit.message.split(/[\r\n]/)[0], + timestamp: commit.createTime?.getTime() ?? 0, + description: [commit.sha.slice(0, 7), commit.author].filter(Boolean).join(', '), + tooltip: getCommitTooltip(commit), + iconPath: commit.avatarUrl ? vscode.Uri.parse(commit.avatarUrl) : new vscode.ThemeIcon('git-commit'), + contextValue: 'github1s:timeline:commit', + command: { + title: 'Open Changes', + command: 'github1s.commands.diffCommitFile', + arguments: [router.buildUri({ ref: commit.sha }, uri)], + }, + }; + } +} diff --git a/extensions/github1s/src/repository/commit-manager.ts b/extensions/github1s/src/repository/commit-manager.ts index 951652a7c..87cfad0a2 100644 --- a/extensions/github1s/src/repository/commit-manager.ts +++ b/extensions/github1s/src/repository/commit-manager.ts @@ -34,10 +34,10 @@ class CommitChangedFilesManager { getList = reuseable(async (forceUpdate: boolean = false): Promise => { if (forceUpdate || !this._changedFilesList) { this._currentPage = 1; - this._changedFilesList = []; + this._changedFilesList = null; await this.loadMore(); } - return this._changedFilesList; + return this._changedFilesList || []; }); loadMore = reuseable(async (): Promise => { @@ -64,20 +64,19 @@ class CommitChangedFilesManager { } } +const historyKey = (from: string, filePath: string) => `${from} ${filePath}`; + export class CommitManager { private static instancesMap = new Map(); - private static _commitMap = new Map(); // commitSha -> CommitWithDirection - // if `previous` or `next` is null, it means this is an end node - private static _relationMap = new Map>(); - - private _latestCommitSha: string | null = null; - private _currentPage = 1; + private _shaMap = new Map(); // sha -> commit + // historyKey -> [sha[], page, hasMore] + private _historyMap = new Map(); private _pageSize = 100; - public static getInstance(scheme: string, repo: string, from: string, filePath: string) { - const mapKey = `${scheme} ${repo} ${from} ${filePath}`; + public static getInstance(scheme: string, repo: string) { + const mapKey = `${scheme} ${repo}`; if (!CommitManager.instancesMap.has(mapKey)) { - CommitManager.instancesMap.set(mapKey, new CommitManager(scheme, repo, from, filePath)); + CommitManager.instancesMap.set(mapKey, new CommitManager(scheme, repo)); } return CommitManager.instancesMap.get(mapKey)!; } @@ -85,155 +84,140 @@ export class CommitManager { private constructor( private _scheme: string, private _repo: string, - private _from: string, - private _filePath: string, ) {} - // link two commitSha - private linkCommitShas(previousCommitSha: string | null, nextCommitSha: string | null) { - if (!CommitManager._relationMap.has(this._filePath)) { - CommitManager._relationMap.set(this._filePath, new Map()); - } - const relation = CommitManager._relationMap.get(this._filePath)!; - if (previousCommitSha) { - !relation.has(previousCommitSha) && relation.set(previousCommitSha, {}); - relation.get(previousCommitSha)!.next = nextCommitSha; - } - if (nextCommitSha) { - !relation.has(nextCommitSha) && relation.set(nextCommitSha, {}); - relation.get(nextCommitSha)!.previous = previousCommitSha; - } - } - - // construct commit list with commit relations - private resolveCommitList() { - const commitList: Commit[] = []; - const relation = CommitManager._relationMap.get(this._filePath); - let currentCommitSha: string | undefined | null = this._latestCommitSha; - while (currentCommitSha && CommitManager._commitMap.has(currentCommitSha)) { - const commit = CommitManager._commitMap.get(currentCommitSha)!; - commitList.push(commit); - currentCommitSha = relation?.get(commit.sha)?.previous; - } - return commitList; + private async fetchCommits(from: string, page: number, filePath: string): Promise { + const dataSource = await getAdapter(this._scheme).resolveDataSource(); + const commits = await dataSource.provideCommits(this._repo, { + from, + page, + pageSize: this._pageSize, + ...(!filePath || filePath === '/' ? {} : { path: filePath }), + }); + commits.forEach((commit) => this._shaMap.set(commit.sha, commit)); + return commits; } - getList = reuseable(async (forceUpdate: boolean = false): Promise => { - const hasMore = await this.hasMore(); - const commitList = this.resolveCommitList(); - const shouldLoadMore = hasMore && commitList.length < this._pageSize; - - if (forceUpdate || shouldLoadMore) { - this._currentPage = 1; - this._latestCommitSha = null; - CommitManager._relationMap.set(this._filePath, new Map()); - await this.loadMore(); + getList = reuseable(async (from: string, filePath: string, forceUpdate = false): Promise => { + let history = this._historyMap.get(historyKey(from, filePath)); + if (forceUpdate || !history) { + const commits = await this.fetchCommits(from, 1, filePath); + const hasMore = commits.length >= this._pageSize; + history = [commits.map((commit) => commit.sha), 1, hasMore] as [string[], number, boolean]; + this._historyMap.set(historyKey(from, filePath), history); + if (commits.length > 0 && from !== commits[0].sha) { + // also cache the history for the latest commit sha, so that + // we can get the history from the latest commit sha directly + // Note when filePath is not empty, ref may not be history[0][0] + this._historyMap.set(historyKey(commits[0].sha, filePath), history); + } } - return this.resolveCommitList(); + return history[0].map((sha) => this._shaMap.get(sha)!).filter(Boolean); }); - getItem = reuseable(async (forceUpdate: boolean = false): Promise => { - if (forceUpdate || !CommitManager._commitMap.has(this._from)) { + getItem = reuseable(async (ref: string, forceUpdate = false): Promise => { + const sha = this._historyMap.get(historyKey(ref, '/'))?.[0]?.[0] || ref; + let commit: Commit | null | undefined = this._shaMap.get(sha); + if (forceUpdate || !commit) { const dataSource = await getAdapter(this._scheme).resolveDataSource(); - const commit = await dataSource.provideCommit(this._repo, this._from); - - commit && CommitManager._commitMap.set(this._from, commit); - commit && CommitManager._commitMap.set(commit.sha, commit); - if (commit?.files) { - const manager = CommitChangedFilesManager.getInstance(this._scheme, this._repo, commit.sha); - manager.setChangedFiles(commit.files); - } + commit = await dataSource.provideCommit(this._repo, ref); + commit && this._shaMap.set(commit.sha, commit); } - return CommitManager._commitMap.get(this._from)!; + return commit; }); - loadMore = reuseable(async (): Promise => { - const commitList = this.resolveCommitList(); - const dataSource = await getAdapter(this._scheme).resolveDataSource(); - const queryOptions = { - page: this._currentPage, - pageSize: this._pageSize, - from: this._from, - path: this._filePath, - }; - const commits = await dataSource.provideCommits(this._repo, queryOptions); - - if (this._currentPage === 1 && commits.length) { - this._latestCommitSha = commits[0].sha; - // also map `this._from` to the first commit for repository history - this._filePath === '/' && CommitManager._commitMap.set(this._from, commits[0]); - } - commits.forEach((commit) => { - CommitManager._commitMap.set(commit.sha, commit); - // directly set changed files if they are in response - if (commit?.files) { - const manager = CommitChangedFilesManager.getInstance(this._scheme, this._repo, commit.sha); - manager.setChangedFiles(commit.files); - } - }); - if (this._currentPage > 1 && commitList.length && commits.length) { - this.linkCommitShas(commits[0].sha, commitList[commitList.length - 1].sha); - } - for (let i = 1, len = commits.length; i < len; i++) { - const previousCommitSha = commits[i].sha; - const nextCommitSha = commits[i - 1].sha; - this.linkCommitShas(previousCommitSha, nextCommitSha); + loadMore = reuseable(async (from: string, filePath: string): Promise => { + const key = historyKey(from, filePath); + let history = this._historyMap.get(key); + if (history && !history[2]) { + // no more commits to load + return Promise.resolve([]); } - // if has more commits - const hasMore = commits.length === this._pageSize; - if (!hasMore) { - const latestCommit = commits.length ? commits[commits.length - 1] : commitList[commitList.length - 1]; - this.linkCommitShas(null, latestCommit.sha); + const fromSha = history?.[0]?.[0] || from; + const nextPage = (history?.[1] || 0) + 1; + const commits = await this.fetchCommits(fromSha, nextPage, filePath); + // Ignore responses if a refresh replaced the history or another request already loaded this page. + if (this._historyMap.get(key) !== history || (history && history[1] >= nextPage)) { + return []; } - this._currentPage += 1; + history = history || ([[], 0, true] as [string[], number, boolean]); + history[0].push(...commits.map((commit) => commit.sha)); + history[1] = nextPage; + history[2] = commits.length >= this._pageSize; + this._historyMap.set(key, history); return commits; }); - hasMore = reuseable(async (): Promise => { - const commitList = this.resolveCommitList(); - const relation = CommitManager._relationMap.get(this._filePath); - const commitRelation = commitList.length ? relation?.get(commitList[commitList.length - 1].sha) : null; - return !commitRelation || commitRelation.previous !== null; - }); + async hasMore(from: string, filePath: string): Promise { + const history = this._historyMap.get(historyKey(from, filePath)); + return history ? history[2] : true; + } - public getChangedFiles = reuseable(async (forceUpdate: boolean = false): Promise => { - const commit = await this.getItem(); + public getChangedFiles = reuseable(async (ref: string, forceUpdate = false): Promise => { + const commit = await this.getItem(ref); const manager = commit ? CommitChangedFilesManager.getInstance(this._scheme, this._repo, commit.sha) : null; return manager ? manager.getList(forceUpdate) : []; }); - public loadMoreChangedFiles = reuseable(async (): Promise => { - const commit = await this.getItem(); + public loadMoreChangedFiles = reuseable(async (ref: string): Promise => { + const commit = await this.getItem(ref); const manager = commit ? CommitChangedFilesManager.getInstance(this._scheme, this._repo, commit.sha) : null; return manager ? manager.loadMore() : []; }); - public hasMoreChangedFiles = reuseable(async (): Promise => { - const commit = await this.getItem(); + public hasMoreChangedFiles = reuseable(async (ref: string): Promise => { + const commit = await this.getItem(ref); const manager = commit ? CommitChangedFilesManager.getInstance(this._scheme, this._repo, commit.sha) : null; return manager ? manager.hasMore() : false; }); - // get the lastest commit of `file with modifications`, - // the commit of `this._from` could be newer than result - public getLatestCommit = reuseable(async (): Promise => { - const commit = this._latestCommitSha ? CommitManager._commitMap.get(this._latestCommitSha) : null; - return commit || (await this.loadMore())[0] || null; + // The latest commit touching the file may be older than the requested revision. + public getLatestCommit = reuseable(async (from: string, filePath: string): Promise => { + const commits = await this.getList(from, filePath); + return commits[0] || null; }); - public getPreviousCommit = reuseable(async (): Promise => { - const commit = await this.getItem(); - const commitRelation = commit ? CommitManager._relationMap.get(this._filePath)?.get(commit.sha) : null; - if (!commitRelation || commitRelation.previous === undefined) { - return (await this.loadMore())[0] || null; + // Keep the original history's `from`: at merges, the previous file revision + // in that list can differ from the one found by querying from `sha`. + public getPreviousCommit = reuseable(async (sha: string, filePath: string, from: string): Promise => { + const key = historyKey(from, filePath); + let history = this._historyMap.get(key); + if (!history) { + // no history for the file, fetch the history first. + await this.getList(from, filePath); + history = this._historyMap.get(key)!; + } + + if (history[0].indexOf(sha) < 0) { + // Should appear only after moving forward a lot (exceeds page size) + // and already lost the history (For example, refreshed the page) + throw new Error(`Commit not found in the history`); + } + + let index = history[0].indexOf(sha); + if (index == history[0].length - 1 && history[2]) { + // we should load more commits if the current commit + // is the last one and there are more commits to load + await this.loadMore(from || sha, filePath); + history = this._historyMap.get(key); + index = history?.[0].indexOf(sha) ?? -1; } - return (commitRelation.previous ? CommitManager._commitMap.get(commitRelation.previous) : null) || null; + // use the first commit if index is -1 + const targetSha = history?.[0][index + 1]; + return targetSha ? this._shaMap.get(targetSha) || null : null; }); - public getNextCommit = reuseable(async (): Promise => { - const commit = await this.getItem(); - const commitRelation = commit ? CommitManager._relationMap.get(this._filePath)?.get(commit.sha) : null; - const nextCommitSha = commitRelation ? commitRelation.next : null; - return (nextCommitSha ? CommitManager._commitMap.get(nextCommitSha) : null) || null; + // Newer entries depend on the original history's `from`; a commit + // SHA and file path alone do not provide that navigation context. + public getNextCommit = reuseable(async (sha: string, filePath: string, from: string): Promise => { + const key = historyKey(from, filePath); + let history = this._historyMap.get(key); + if (!history) { + // no history for the file, fetch the history first. + await this.getList(from, filePath); + history = this._historyMap.get(key)!; + } + const index = history?.[0].indexOf(sha) ?? -1; + return index > 0 ? this._shaMap.get(history![0][index - 1]) || null : null; }); } diff --git a/extensions/github1s/src/repository/index.ts b/extensions/github1s/src/repository/index.ts index 2379cffa3..f66c41f91 100644 --- a/extensions/github1s/src/repository/index.ts +++ b/extensions/github1s/src/repository/index.ts @@ -15,6 +15,7 @@ export class Repository { private _branchTagManager: BranchTagManager; private _codeReviewManager: CodeReviewManager; + private _commitManager: CommitManager; private _blameRangesCache: Map; public static getInstance(scheme: string, repo: string) { @@ -30,11 +31,12 @@ export class Repository { } private constructor( - private _scheme: string, - private _repo: string, + public readonly scheme: string, + public readonly repo: string, ) { - this._branchTagManager = BranchTagManager.getInstance(_scheme, _repo); - this._codeReviewManager = CodeReviewManager.getInstance(_scheme, _repo); + this._branchTagManager = BranchTagManager.getInstance(scheme, repo); + this._codeReviewManager = CodeReviewManager.getInstance(scheme, repo); + this._commitManager = CommitManager.getInstance(scheme, repo); this._blameRangesCache = new Map(); } @@ -71,43 +73,43 @@ export class Repository { } getCommitList(ref: string = 'HEAD', filePath: string = '/', forceUpdate: boolean = false) { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).getList(forceUpdate); + return this._commitManager.getList(ref, filePath, forceUpdate); } getCommitItem(ref: string, forceUpdate: boolean = false) { - return CommitManager.getInstance(this._scheme, this._repo, ref, '/').getItem(forceUpdate); + return this._commitManager.getItem(ref, forceUpdate); } loadMoreCommits(ref: string = 'HEAD', filePath: string = '/') { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).loadMore(); + return this._commitManager.loadMore(ref, filePath); } hasMoreCommits(ref: string = 'HEAD', filePath: string = '/') { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).hasMore(); + return this._commitManager.hasMore(ref, filePath); } getCommitChangedFiles(ref: string, forceUpdate: boolean = false) { - return CommitManager.getInstance(this._scheme, this._repo, ref, '/').getChangedFiles(forceUpdate); + return this._commitManager.getChangedFiles(ref, forceUpdate); } loadMoreCommitChangedFiles(ref: string) { - return CommitManager.getInstance(this._scheme, this._repo, ref, '/').loadMoreChangedFiles(); + return this._commitManager.loadMoreChangedFiles(ref); } hasMoreCommitChangedFiles(ref: string) { - return CommitManager.getInstance(this._scheme, this._repo, ref, '/').hasMoreChangedFiles(); + return this._commitManager.hasMoreChangedFiles(ref); } getFileLatestCommit(ref: string, filePath: string) { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).getLatestCommit(); + return this._commitManager.getLatestCommit(ref, filePath); } - getPreviousCommit(ref: string, filePath: string) { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).getPreviousCommit(); + getPreviousCommit(sha: string, filePath: string, fromSha: string) { + return this._commitManager.getPreviousCommit(sha, filePath, fromSha); } - getNextCommit(ref: string, filePath: string) { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).getNextCommit(); + getNextCommit(sha: string, filePath: string, fromSha: string) { + return this._commitManager.getNextCommit(sha, filePath, fromSha); } getCodeReviewList(...args: Parameters) { @@ -141,8 +143,8 @@ export class Repository { async getFileBlameRanges(ref: string, path: string) { const cacheKey = `${ref} ${path}`; if (!this._blameRangesCache.has(cacheKey)) { - const dataSource = await getAdapter(this._scheme).resolveDataSource(); - const blameRanges = await dataSource.provideFileBlameRanges(this._repo, ref, path); + const dataSource = await getAdapter(this.scheme).resolveDataSource(); + const blameRanges = await dataSource.provideFileBlameRanges(this.repo, ref, path); this._blameRangesCache.set(cacheKey, blameRanges); } return this._blameRangesCache.get(cacheKey) || []; diff --git a/extensions/github1s/src/statusbar/index.ts b/extensions/github1s/src/statusbar/index.ts index 594391d6d..43ff49c89 100644 --- a/extensions/github1s/src/statusbar/index.ts +++ b/extensions/github1s/src/statusbar/index.ts @@ -4,9 +4,9 @@ */ import { updateCheckoutTo } from './checkout'; -import { showSponsors } from './sponsors'; +import { showThanks } from './thanks'; export const decorateStatusBar = () => { updateCheckoutTo(); - showSponsors(); + showThanks(); }; diff --git a/extensions/github1s/src/statusbar/sponsors.ts b/extensions/github1s/src/statusbar/thanks.ts similarity index 66% rename from extensions/github1s/src/statusbar/sponsors.ts rename to extensions/github1s/src/statusbar/thanks.ts index 60e78e32b..0da8d2832 100644 --- a/extensions/github1s/src/statusbar/sponsors.ts +++ b/extensions/github1s/src/statusbar/thanks.ts @@ -1,5 +1,5 @@ /** - * @file Show Sponsors In Status Bar + * @file Show Thanks In Status Bar * @author netcon */ @@ -22,7 +22,7 @@ const resolveSourcegraphLink = async () => { } }; -const resolveSponsors = async () => { +const resolveThanks = async () => { return [ { name: 'Sourcegraph', @@ -32,20 +32,20 @@ const resolveSponsors = async () => { ]; }; -export const showSponsors = async () => { +export const showThanks = async () => { const titleItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); - titleItem.text = ' $(heart) Sponsors:'; + titleItem.text = ' $(heart) Thanks:'; titleItem.show(); - (await resolveSponsors()).forEach((sponsor) => { - const sponsorItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); - sponsorItem.text = sponsor.name; - sponsorItem.tooltip = sponsor.description; - sponsorItem.command = { - title: `Visit ${sponsor.name}`, + (await resolveThanks()).forEach((service) => { + const serviceItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); + serviceItem.text = service.name; + serviceItem.tooltip = service.description; + serviceItem.command = { + title: `Visit ${service.name}`, command: 'vscode.open', - arguments: [vscode.Uri.parse(sponsor.link)], + arguments: [vscode.Uri.parse(service.link)], }; - sponsorItem.show(); + serviceItem.show(); }); }; diff --git a/extensions/github1s/src/views/commit-list.ts b/extensions/github1s/src/views/commit-list.ts deleted file mode 100644 index 5e37412a6..000000000 --- a/extensions/github1s/src/views/commit-list.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * @file GitHub Commit List View - * @author netcon - */ - -import * as vscode from 'vscode'; -import router from '@/router'; -import { getAdapter } from '@/adapters'; -import { Repository } from '@/repository'; -import { Barrier } from '@/helpers/async'; -import queryString from 'query-string'; -import { relativeTimeTo, toISOString } from '@/helpers/date'; -import * as adapterTypes from '@/adapters/types'; -import { getChangedFileDiffCommand, getCommitChangedFiles } from '@/changes/files'; -import { GitHub1sSourceControlDecorationProvider } from '@/providers/decorations/source-control'; -import { getFileTreeItemDescription } from '@/helpers/util'; - -export const getCommitTreeItemDescription = (commit: adapterTypes.Commit): string => { - const shortCommitSha = commit.sha.slice(0, 7); - const relativeTimeStr = commit.createTime ? relativeTimeTo(commit.createTime) : null; - return [shortCommitSha, commit.author, relativeTimeStr].filter(Boolean).join(', '); -}; - -export const getCommitTreeItemTooltip = (commit: adapterTypes.Commit): string => { - const shortCommitSha = commit.sha.slice(0, 7); - const ISOTimeStr = commit.createTime ? toISOString(commit.createTime) : null; - const detailText = [shortCommitSha, commit.author, ISOTimeStr].filter(Boolean).join(', '); - return `${commit.message}\n(${detailText})`; -}; - -export interface CommitTreeItem extends vscode.TreeItem { - commit: adapterTypes.Commit; -} - -export class CommitTreeDataProvider implements vscode.TreeDataProvider { - public static viewType = 'github1s.views.commitList'; - - protected _forceUpdate = false; - protected _loadingBarrier: Barrier | null = null; - protected _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - protected loadMoreCommitItem: vscode.TreeItem = { - label: 'Load more', - tooltip: 'Load more commits', - command: { - title: 'Load more commits', - command: 'github1s.commands.loadMoreCommits', - tooltip: 'Load more commits', - }, - }; - - protected createLoadMoreChangedFilesItem = (commitSha: string): vscode.TreeItem => ({ - label: 'Load more', - tooltip: 'Load more changed files', - command: { - title: 'Load more changed files', - command: 'github1s.commands.loadMoreCommitChangedFiles', - tooltip: 'Load more changed files', - arguments: [commitSha], - }, - }); - - async resolveFilePath() { - return '/'; - } - - public updateTree(forceUpdate = true) { - this._forceUpdate = forceUpdate; - this._onDidChangeTreeData.fire(); - } - - public async loadMoreCommits() { - if (!this._loadingBarrier || this._loadingBarrier.isOpen()) { - this._loadingBarrier = new Barrier(5000); - this.updateTree(false); - const { ref } = router.getState(); - const repository = Repository.getCurrentInstance(); - await repository.loadMoreCommits(ref, await this.resolveFilePath()); - this._loadingBarrier.open(); - } - } - - public async loadMoreChangedFiles(commitSha: string) { - if (!this._loadingBarrier || this._loadingBarrier.isOpen()) { - this._loadingBarrier = new Barrier(5000); - this.updateTree(false); - const repository = Repository.getCurrentInstance(); - await repository.loadMoreCommitChangedFiles(commitSha); - this._loadingBarrier.open(); - } - } - - async getCommitItems(): Promise { - this._loadingBarrier && (await this._loadingBarrier.wait()); - const filePath = await this.resolveFilePath(); - const { ref } = router.getState(); - const repository = Repository.getCurrentInstance(); - const repositoryCommits = await repository.getCommitList(ref, filePath, this._forceUpdate); - const commitTreeItems = repositoryCommits.map((commit) => { - const label = commit.message.split(/[\r\n]/)[0]; - const description = getCommitTreeItemDescription(commit); - const tooltip = getCommitTreeItemTooltip(commit); - const iconPath = vscode.Uri.parse(commit.avatarUrl || ''); - const contextValue = 'github1s:viewItems:commitListItem'; - - return { - commit, - label, - iconPath, - description, - tooltip, - contextValue, - resourceUri: vscode.Uri.from({ - scheme: GitHub1sSourceControlDecorationProvider.commitSchema, - query: queryString.stringify({ sha: commit.sha }), - }), - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - }; - }); - this._forceUpdate = false; - const hasMore = await repository.hasMoreCommits(ref, filePath); - return hasMore ? [...commitTreeItems, this.loadMoreCommitItem] : commitTreeItems; - } - - async getCommitFileItems(commit: adapterTypes.Commit): Promise { - const repository = Repository.getCurrentInstance(); - const changedFiles = await getCommitChangedFiles(commit); - const changedFileItems = changedFiles.map((changedFile) => { - const filePath = changedFile.headFileUri.path; - const id = `${commit.sha} ${filePath}`; - const command = getChangedFileDiffCommand(changedFile); - - return { - id, - command, - description: getFileTreeItemDescription(filePath), - resourceUri: changedFile.headFileUri.with({ - query: queryString.stringify({ changeStatus: changedFile.status }), - }), - collapsibleState: vscode.TreeItemCollapsibleState.None, - }; - }); - const hasMore = await repository.hasMoreCommitChangedFiles(commit.sha); - const loadMoreChangedFilesItem = this.createLoadMoreChangedFilesItem(commit.sha); - return hasMore ? [...changedFileItems, loadMoreChangedFilesItem] : changedFileItems; - } - - getTreeItem(element: vscode.TreeItem): vscode.TreeItem | Thenable { - return element; - } - - getChildren(element?: vscode.TreeItem): vscode.ProviderResult { - if (!element) { - return this.getCommitItems(); - } - const commit = (element as CommitTreeItem)?.commit; - return commit ? this.getCommitFileItems(commit) : []; - } -} - -export class FileHistoryTreeDataProvider extends CommitTreeDataProvider { - public static viewType = 'github1s.views.fileHistory'; - - protected loadMoreCommitItem: vscode.TreeItem = { - label: 'Load more', - tooltip: 'Load more commits', - command: { - title: 'Load more commits', - command: 'github1s.commands.loadMoreFileHistoryCommits', - tooltip: 'Load more commits', - }, - }; - - protected createLoadMoreChangedFilesItem = (commitSha: string): vscode.TreeItem => ({ - label: 'Load more', - tooltip: 'Load more changed files', - command: { - title: 'Load more changed files', - command: 'github1s.commands.loadMoreFileHistoryCommitChangedFiles', - tooltip: 'Load more changed files', - arguments: [commitSha], - }, - }); - - async resolveFilePath() { - const activeDocumentUri = vscode.window.activeTextEditor?.document?.uri; - return activeDocumentUri?.scheme === getAdapter().scheme ? activeDocumentUri.path : '/'; - } - - async getCommitItems() { - if ((await this.resolveFilePath()) === '/') { - return []; - } - return super.getCommitItems(); - } -} diff --git a/extensions/github1s/src/views/index.ts b/extensions/github1s/src/views/index.ts index a59956a9b..5218f2834 100644 --- a/extensions/github1s/src/views/index.ts +++ b/extensions/github1s/src/views/index.ts @@ -7,10 +7,7 @@ import { getAdapter } from '@/adapters'; import { CodeReviewType } from '@/adapters/types'; import { getExtensionContext } from '@/helpers/context'; import { CodeReviewTreeDataProvider } from './code-review-list'; -import { CommitTreeDataProvider, FileHistoryTreeDataProvider } from './commit-list'; -export const fileHistoryTreeDataProvider = new FileHistoryTreeDataProvider(); -export const commitTreeDataProvider = new CommitTreeDataProvider(); export const codeReviewRequestTreeDataProvider = new CodeReviewTreeDataProvider(); export const codeReviewViewTitle = { @@ -31,9 +28,5 @@ export const registerCustomViews = () => { const codeReviewType = getAdapter().codeReviewType || CodeReviewType.CodeReview; codeReviewTreeView.title = codeReviewViewTitle[codeReviewType]; - context.subscriptions.push( - // register commit view which is in source control panel - vscode.window.registerTreeDataProvider(FileHistoryTreeDataProvider.viewType, fileHistoryTreeDataProvider), - vscode.window.registerTreeDataProvider(CommitTreeDataProvider.viewType, commitTreeDataProvider), - ); + context.subscriptions.push(codeReviewTreeView); }; diff --git a/extensions/github1s/src/vscode.proposed.d.ts b/extensions/github1s/src/vscode.proposed.d.ts index 4eff483da..6f3d03cb5 100644 --- a/extensions/github1s/src/vscode.proposed.d.ts +++ b/extensions/github1s/src/vscode.proposed.d.ts @@ -360,3 +360,277 @@ declare module 'vscode' { export function registerTextSearchProvider(scheme: string, provider: TextSearchProvider): Disposable; } } + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // https://github.com/microsoft/vscode/issues/185269 + + export interface SourceControl { + historyProvider?: SourceControlHistoryProvider; + } + + export interface SourceControlHistoryProvider { + readonly currentHistoryItemRef: SourceControlHistoryItemRef | undefined; + readonly currentHistoryItemRemoteRef: SourceControlHistoryItemRef | undefined; + readonly currentHistoryItemBaseRef: SourceControlHistoryItemRef | undefined; + + /** + * Fires when the current history item refs (local, remote, base) + * change after a user action (ex: commit, checkout, fetch, pull, push) + */ + readonly onDidChangeCurrentHistoryItemRefs: Event; + + /** + * Fires when history item refs change + */ + readonly onDidChangeHistoryItemRefs: Event; + + provideHistoryItemRefs( + historyItemRefs: string[] | undefined, + token: CancellationToken, + ): ProviderResult; + provideHistoryItems( + options: SourceControlHistoryOptions, + token: CancellationToken, + ): ProviderResult; + provideHistoryItemChanges( + historyItemId: string, + historyItemParentId: string | undefined, + token: CancellationToken, + ): ProviderResult; + + resolveHistoryItem(historyItemId: string, token: CancellationToken): ProviderResult; + resolveHistoryItemChatContext(historyItemId: string, token: CancellationToken): ProviderResult; + resolveHistoryItemChangeRangeChatContext( + historyItemId: string, + historyItemParentId: string, + path: string, + token: CancellationToken, + ): ProviderResult; + resolveHistoryItemRefsCommonAncestor(historyItemRefs: string[], token: CancellationToken): ProviderResult; + } + + export interface SourceControlHistoryOptions { + readonly skip?: number; + readonly limit?: number | { id?: string }; + readonly historyItemRefs?: readonly string[]; + readonly filterText?: string; + } + + export interface SourceControlHistoryItemStatistics { + readonly files: number; + readonly insertions: number; + readonly deletions: number; + } + + export interface SourceControlHistoryItem { + readonly id: string; + readonly parentIds: string[]; + readonly subject: string; + readonly message: string; + readonly displayId?: string; + readonly author?: string; + readonly authorEmail?: string; + readonly authorIcon?: IconPath; + readonly timestamp?: number; + readonly statistics?: SourceControlHistoryItemStatistics; + readonly references?: SourceControlHistoryItemRef[]; + readonly tooltip?: MarkdownString | Array | undefined; + } + + export interface SourceControlHistoryItemRef { + readonly id: string; + readonly name: string; + readonly description?: string; + readonly revision?: string; + readonly category?: string; + readonly icon?: IconPath; + } + + export interface SourceControlHistoryItemChange { + readonly uri: Uri; + readonly originalUri: Uri | undefined; + readonly modifiedUri: Uri | undefined; + } + + export interface SourceControlHistoryItemRefsChangeEvent { + readonly added: readonly SourceControlHistoryItemRef[]; + readonly removed: readonly SourceControlHistoryItemRef[]; + readonly modified: readonly SourceControlHistoryItemRef[]; + + /** + * Flag to indicate if the operation that caused the event to trigger was due + * to a user action or a background operation (ex: Auto Fetch). The flag is used + * to determine whether to automatically refresh the user interface or present + * the user with a visual cue that the user interface is outdated. + */ + readonly silent: boolean; + } +} + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // https://github.com/microsoft/vscode/issues/84297 + + export class TimelineItem { + /** + * A timestamp (in milliseconds since 1 January 1970 00:00:00) for when the timeline item occurred. + */ + timestamp: number; + + /** + * A human-readable string describing the timeline item. + */ + label: string; + + /** + * Optional id for the timeline item. It must be unique across all the timeline items provided by this source. + * + * If not provided, an id is generated using the timeline item's timestamp. + */ + id?: string; + + /** + * The icon path or {@link ThemeIcon} for the timeline item. + */ + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon; + + /** + * A human readable string describing less prominent details of the timeline item. + */ + description?: string; + + /** + * The tooltip text when you hover over the timeline item. + */ + tooltip?: string | MarkdownString | undefined; + + /** + * The {@link Command} that should be executed when the timeline item is selected. + */ + command?: Command; + + /** + * Context value of the timeline item. This can be used to contribute specific actions to the item. + * For example, a timeline item is given a context value as `commit`. When contributing actions to `timeline/item/context` + * using `menus` extension point, you can specify context value for key `timelineItem` in `when` expression like `timelineItem == commit`. + * ``` + * "contributes": { + * "menus": { + * "timeline/item/context": [ + * { + * "command": "extension.copyCommitId", + * "when": "timelineItem == commit" + * } + * ] + * } + * } + * ``` + * This will show the `extension.copyCommitId` action only for items where `contextValue` is `commit`. + */ + contextValue?: string; + + /** + * Accessibility information used when screen reader interacts with this timeline item. + */ + accessibilityInformation?: AccessibilityInformation; + + /** + * @param label A human-readable string describing the timeline item + * @param timestamp A timestamp (in milliseconds since 1 January 1970 00:00:00) for when the timeline item occurred + */ + constructor(label: string, timestamp: number); + } + + export interface TimelineChangeEvent { + /** + * The {@link Uri} of the resource for which the timeline changed. + */ + uri: Uri; + + /** + * A flag which indicates whether the entire timeline should be reset. + */ + reset?: boolean; + } + + export interface Timeline { + readonly paging?: { + /** + * A provider-defined cursor specifying the starting point of timeline items which are after the ones returned. + * Use `undefined` to signal that there are no more items to be returned. + */ + readonly cursor: string | undefined; + }; + + /** + * An array of {@link TimelineItem timeline items}. + */ + readonly items: readonly TimelineItem[]; + } + + export interface TimelineOptions { + /** + * A provider-defined cursor specifying the starting point of the timeline items that should be returned. + */ + cursor?: string; + + /** + * An optional maximum number timeline items or the all timeline items newer (inclusive) than the timestamp or id that should be returned. + * If `undefined` all timeline items should be returned. + */ + limit?: number | { timestamp: number; id?: string }; + } + + export interface TimelineProvider { + /** + * An optional event to signal that the timeline for a source has changed. + * To signal that the timeline for all resources (uris) has changed, do not pass any argument or pass `undefined`. + */ + readonly onDidChange?: Event; + + /** + * An identifier of the source of the timeline items. This can be used to filter sources. + */ + readonly id: string; + + /** + * A human-readable string describing the source of the timeline items. This can be used as the display label when filtering sources. + */ + readonly label: string; + + /** + * Provide {@link TimelineItem timeline items} for a {@link Uri}. + * + * @param uri The {@link Uri} of the file to provide the timeline for. + * @param options A set of options to determine how results should be returned. + * @param token A cancellation token. + * @return The {@link TimelineResult timeline result} or a thenable that resolves to such. The lack of a result + * can be signaled by returning `undefined`, `null`, or an empty array. + */ + provideTimeline(uri: Uri, options: TimelineOptions, token: CancellationToken): ProviderResult; + } + + export namespace workspace { + /** + * Register a timeline provider. + * + * Multiple providers can be registered. In that case, providers are asked in + * parallel and the results are merged. A failing provider (rejected promise or exception) will + * not cause a failure of the whole operation. + * + * @param scheme A scheme or schemes that defines which documents this provider is applicable to. Can be `*` to target all documents. + * @param provider A timeline provider. + * @return A {@link Disposable} that unregisters this provider when being disposed. + */ + export function registerTimelineProvider(scheme: string | string[], provider: TimelineProvider): Disposable; + } +} diff --git a/package-lock.json b/package-lock.json index 80357c23f..ea1a41e6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "ISC", "devDependencies": { "@cloudflare/workers-types": "^5.20260910.1", - "@github1s/vscode-web": "^0.30.2", + "@github1s/vscode-web": "^0.30.3", "@types/node": "^22.10.5", "chokidar": "^4.0.3", "clean-css": "^5.3.3", @@ -252,9 +252,9 @@ } }, "node_modules/@github1s/vscode-web": { - "version": "0.30.2", - "resolved": "https://registry.npmjs.org/@github1s/vscode-web/-/vscode-web-0.30.2.tgz", - "integrity": "sha512-k4xjaMbGK1dhn6EmZSbm9o68i/8yqClC3T24EhIcb6Drago+smgpx+NdkLo9aGDrfjvF5bJZlfWV+XcVZqfa5g==", + "version": "0.30.3", + "resolved": "https://registry.npmjs.org/@github1s/vscode-web/-/vscode-web-0.30.3.tgz", + "integrity": "sha512-wK6fvcCTSPK8EIQZA+o4IGBQLwudOA/TgG+0C+1sI4TvGG2lu/wCi2RNMIjJZpU4hzcaR0T3RQi7PtnBdiGQmA==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 2d6460f37..2199c35e2 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "license": "ISC", "devDependencies": { "@cloudflare/workers-types": "^5.20260910.1", - "@github1s/vscode-web": "^0.30.2", + "@github1s/vscode-web": "^0.30.3", "@types/node": "^22.10.5", "chokidar": "^4.0.3", "clean-css": "^5.3.3", diff --git a/resources/images/demo.png b/resources/images/demo.png index 0f148ad6a..d88d994ea 100644 Binary files a/resources/images/demo.png and b/resources/images/demo.png differ diff --git a/resources/images/gitpod-statusbar.png b/resources/images/gitpod-statusbar.png deleted file mode 100644 index 6b90bc61b..000000000 Binary files a/resources/images/gitpod-statusbar.png and /dev/null differ diff --git a/resources/images/logo.png b/resources/images/logo.png deleted file mode 100644 index 56af2635e..000000000 Binary files a/resources/images/logo.png and /dev/null differ diff --git a/resources/images/ossinsight-brand-dark.png b/resources/images/ossinsight-brand-dark.png deleted file mode 100644 index 820291a21..000000000 Binary files a/resources/images/ossinsight-brand-dark.png and /dev/null differ diff --git a/resources/images/sourcegraph-logo.svg b/resources/images/sourcegraph-logo.svg deleted file mode 100644 index 6bc102b3a..000000000 --- a/resources/images/sourcegraph-logo.svg +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/resources/images/vercel-logo.svg b/resources/images/vercel-logo.svg deleted file mode 100644 index 6ed36d3b7..000000000 --- a/resources/images/vercel-logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/tests/__tests__/__image_snapshots__/index-test-ts-should-load-successfully-1-snap.png b/tests/__tests__/__image_snapshots__/index-test-ts-should-load-successfully-1-snap.png index eb2358883..271d4ad03 100644 Binary files a/tests/__tests__/__image_snapshots__/index-test-ts-should-load-successfully-1-snap.png and b/tests/__tests__/__image_snapshots__/index-test-ts-should-load-successfully-1-snap.png differ diff --git a/tests/__tests__/__image_snapshots__/index-test-ts-should-open-file-correctly-1-snap.png b/tests/__tests__/__image_snapshots__/index-test-ts-should-open-file-correctly-1-snap.png index 4b239f696..1056d3f3c 100644 Binary files a/tests/__tests__/__image_snapshots__/index-test-ts-should-open-file-correctly-1-snap.png and b/tests/__tests__/__image_snapshots__/index-test-ts-should-open-file-correctly-1-snap.png differ diff --git a/tests/__tests__/index.test.ts b/tests/__tests__/index.test.ts index e1fd01bf5..bf9210dd5 100644 --- a/tests/__tests__/index.test.ts +++ b/tests/__tests__/index.test.ts @@ -62,10 +62,11 @@ it('should load successfully', async () => { it('should open file correctly', async () => { await page.goto(`${BASE_URL}/conwnet/github1s`); - await page.waitForTimeout(3000); + await page.waitForSelector('iframe.webview.ready'); await page.click('[aria-label="~/tsconfig.json"]'); await page.click('[data-resource-name="tsconfig.json"]'); - await page.waitForTimeout(3000); + await page.waitForSelector('[role="tab"][aria-selected="true"][data-resource-name="tsconfig.json"]'); + await page.waitForSelector('.monaco-editor[data-uri="github1s:/tsconfig.json"] .view-lines'); const image = await page.screenshot(); expect(image).toMatchImageSnapshot(matchImageSnapshotOptions); diff --git a/vscode-web/package-lock.json b/vscode-web/package-lock.json index 9faca8ef3..2c72de1cc 100644 --- a/vscode-web/package-lock.json +++ b/vscode-web/package-lock.json @@ -1,12 +1,12 @@ { "name": "@github1s/vscode-web", - "version": "0.30.2", + "version": "0.30.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@github1s/vscode-web", - "version": "0.30.2", + "version": "0.30.3", "license": "MIT", "devDependencies": { "chokidar": "^4.0.3", @@ -90,9 +90,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/vscode-web/package.json b/vscode-web/package.json index e64cf694b..7a0ea0a8d 100644 --- a/vscode-web/package.json +++ b/vscode-web/package.json @@ -1,6 +1,6 @@ { "name": "@github1s/vscode-web", - "version": "0.30.2", + "version": "0.30.3", "description": "VS Code web for GitHub1s", "author": "github1s", "license": "MIT", diff --git a/vscode-web/scripts/.patch b/vscode-web/scripts/.patch index f4246ba24..73e506184 100644 --- a/vscode-web/scripts/.patch +++ b/vscode-web/scripts/.patch @@ -7,5 +7,6 @@ "src/vs/workbench/services/label/common/labelService.ts": "9e4e2aafeabc04ccf183b8f2f4ce8a4d39f240831e4005b06149684458caa8d4", "src/vs/workbench/services/textfile/browser/textFileService.ts": "886566605815367d3866be243994829729fd0b85c04ab7450b9de22ee93053eb", "src/vs/base/common/network.ts": "d264af1ba5dbbfaf950c5da336100fb1242802eab92ed3b4692d3c10282b2dee", - "src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts": "f77c8e0d95ff5e0b15f0ea5135f3b454ee6b12dbf07c793ae3e053ead954e47a" + "src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts": "f77c8e0d95ff5e0b15f0ea5135f3b454ee6b12dbf07c793ae3e053ead954e47a", + "src/vs/workbench/contrib/scm/browser/scm.contribution.ts": "82d17f9575c13d810e9f65ef5e47c68ff007c0155db9eaa0c434d67e67cd63b8" } \ No newline at end of file diff --git a/vscode-web/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/vscode-web/src/vs/workbench/contrib/scm/browser/scm.contribution.ts new file mode 100644 index 000000000..e5b34a129 --- /dev/null +++ b/vscode-web/src/vs/workbench/contrib/scm/browser/scm.contribution.ts @@ -0,0 +1,712 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../nls.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IWorkbenchContributionsRegistry, registerWorkbenchContribution2, Extensions as WorkbenchExtensions, WorkbenchPhase } from '../../../common/contributions.js'; +import { VIEWLET_ID, ISCMService, VIEW_PANE_ID, ISCMProvider, ISCMViewService, REPOSITORIES_VIEW_PANE_ID, HISTORY_VIEW_PANE_ID } from '../common/scm.js'; +import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; +import { MenuRegistry, MenuId, registerAction2, Action2 } from '../../../../platform/actions/common/actions.js'; +import { SCMActiveResourceContextKeyController, SCMActiveRepositoryController } from './activity.js'; +import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IViewContainersRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, IViewsRegistry } from '../../../common/views.js'; +import { SCMViewPaneContainer } from './scmViewPaneContainer.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { ModesRegistry } from '../../../../editor/common/languages/modesRegistry.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { ContextKeys, SCMViewPane } from './scmViewPane.js'; +import { RepositoryPicker } from './scmViewService.js'; +import { SCMRepositoriesViewPane } from './scmRepositoriesViewPane.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { Context as SuggestContext } from '../../../../editor/contrib/suggest/browser/suggest.js'; +import { InlineCompletionContextKeys } from '../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionContextKeys.js'; +import { MANAGE_TRUST_COMMAND_ID, WorkspaceTrustContext } from '../../workspace/common/workspace.js'; +import { getActiveElement, isActiveElement } from '../../../../base/browser/dom.js'; +import { SCMWorkingSetController } from './workingSet.js'; +import { IViewsService } from '../../../services/views/common/viewsService.js'; +import { IListService, WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { isSCMRepository } from './util.js'; +import { SCMHistoryViewPane } from './scmHistoryViewPane.js'; +import { RemoteNameContext, ResourceContextKey } from '../../../common/contextkeys.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { SCMAccessibilityHelp } from './scmAccessibilityHelp.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { SCMHistoryItemContextContribution } from './scmHistoryChatContext.js'; +import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; +import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; +import { SCMInputContextKeys } from './scmInput.js'; +import product from '../../../../platform/product/common/product.js'; + +ModesRegistry.registerLanguage({ + id: 'scminput', + extensions: [], + aliases: [], // hide from language selector + mimetypes: ['text/x-scm-input'] +}); + +const sourceControlViewIcon = registerIcon('source-control-view-icon', Codicon.sourceControl, localize('sourceControlViewIcon', 'View icon of the Source Control view.')); + +const viewContainer = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer({ + id: VIEWLET_ID, + title: localize2('source control', 'Source Control'), + ctorDescriptor: new SyncDescriptor(SCMViewPaneContainer), + storageId: 'workbench.scm.views.state', + icon: sourceControlViewIcon, + alwaysUseContainerInfo: true, + order: 2, + hideIfEmpty: true, +}, ViewContainerLocation.Sidebar, { doNotRegisterOpenCommand: true }); + +const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); +const containerTitle = localize('source control view', "Source Control"); + +viewsRegistry.registerViewWelcomeContent(VIEW_PANE_ID, { + content: localize('no open repo', "No source control providers registered."), + when: 'default' +}); + +viewsRegistry.registerViewWelcomeContent(VIEW_PANE_ID, { + content: localize('no open repo in an untrusted workspace', "None of the registered source control providers work in Restricted Mode."), + when: ContextKeyExpr.and(ContextKeyExpr.equals('scm.providerCount', 0), WorkspaceTrustContext.IsEnabled, WorkspaceTrustContext.IsTrusted.toNegated()) +}); + +viewsRegistry.registerViewWelcomeContent(VIEW_PANE_ID, { + content: `[${localize('manageWorkspaceTrustAction', "Manage Workspace Trust")}](command:${MANAGE_TRUST_COMMAND_ID})`, + when: ContextKeyExpr.and(ContextKeyExpr.equals('scm.providerCount', 0), WorkspaceTrustContext.IsEnabled, WorkspaceTrustContext.IsTrusted.toNegated()) +}); + +viewsRegistry.registerViewWelcomeContent(HISTORY_VIEW_PANE_ID, { + content: localize('no history items', "The selected source control provider does not have any source control history items."), + when: ContextKeys.SCMHistoryItemCount.isEqualTo(0) +}); + +viewsRegistry.registerViews([{ + id: REPOSITORIES_VIEW_PANE_ID, + containerTitle, + name: localize2('scmRepositories', "Repositories"), + singleViewPaneContainerTitle: localize('source control repositories', "Source Control Repositories"), + ctorDescriptor: new SyncDescriptor(SCMRepositoriesViewPane), + canToggleVisibility: true, + hideByDefault: true, + canMoveView: true, + weight: 20, + order: 0, + when: ContextKeyExpr.and(ContextKeyExpr.has('scm.providerCount'), ContextKeyExpr.notEquals('scm.providerCount', 0)), + // readonly when = ContextKeyExpr.or(ContextKeyExpr.equals('config.scm.alwaysShowProviders', true), ContextKeyExpr.and(ContextKeyExpr.notEquals('scm.providerCount', 0), ContextKeyExpr.notEquals('scm.providerCount', 1))); + containerIcon: sourceControlViewIcon +}], viewContainer); + +viewsRegistry.registerViews([{ + id: VIEW_PANE_ID, + containerTitle, + name: localize2('scmChanges', 'Changes'), + singleViewPaneContainerTitle: containerTitle, + ctorDescriptor: new SyncDescriptor(SCMViewPane), + canToggleVisibility: true, + canMoveView: true, + weight: 40, + order: 1, + containerIcon: sourceControlViewIcon, + openCommandActionDescriptor: { + id: viewContainer.id, + mnemonicTitle: localize({ key: 'miViewSCM', comment: ['&& denotes a mnemonic'] }, "Source &&Control"), + keybindings: { + primary: 0, + win: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG }, + linux: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG }, + mac: { primary: KeyMod.WinCtrl | KeyMod.Shift | KeyCode.KeyG }, + }, + order: 2, + } +}], viewContainer); + +viewsRegistry.registerViews([{ + id: HISTORY_VIEW_PANE_ID, + containerTitle, + name: localize2('scmGraph', "Graph"), + singleViewPaneContainerTitle: localize('source control graph', "Source Control Graph"), + ctorDescriptor: new SyncDescriptor(SCMHistoryViewPane), + canToggleVisibility: true, + canMoveView: true, + /* below codes are changed by github1s */ + // default collapsed history view + collapsed: true, + /* above codes are changed by github1s */ + weight: 40, + order: 2, + when: ContextKeyExpr.and( + ContextKeyExpr.has('scm.historyProviderCount'), + ContextKeyExpr.notEquals('scm.historyProviderCount', 0), + ), + containerIcon: sourceControlViewIcon +}], viewContainer); + +Registry.as(WorkbenchExtensions.Workbench) + .registerWorkbenchContribution(SCMActiveRepositoryController, LifecyclePhase.Restored); + +Registry.as(WorkbenchExtensions.Workbench) + .registerWorkbenchContribution(SCMActiveResourceContextKeyController, LifecyclePhase.Restored); + +registerWorkbenchContribution2( + SCMWorkingSetController.ID, + SCMWorkingSetController, + WorkbenchPhase.AfterRestored +); + +registerWorkbenchContribution2( + SCMHistoryItemContextContribution.ID, + SCMHistoryItemContextContribution, + WorkbenchPhase.AfterRestored +); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'scm', + order: 5, + title: localize('scmConfigurationTitle', "Source Control"), + type: 'object', + scope: ConfigurationScope.RESOURCE, + properties: { + 'scm.diffDecorations': { + type: 'string', + enum: ['all', 'gutter', 'overview', 'minimap', 'none'], + enumDescriptions: [ + localize('scm.diffDecorations.all', "Show the diff decorations in all available locations."), + localize('scm.diffDecorations.gutter', "Show the diff decorations only in the editor gutter."), + localize('scm.diffDecorations.overviewRuler', "Show the diff decorations only in the overview ruler."), + localize('scm.diffDecorations.minimap', "Show the diff decorations only in the minimap."), + localize('scm.diffDecorations.none', "Do not show the diff decorations.") + ], + default: 'all', + description: localize('diffDecorations', "Controls diff decorations in the editor.") + }, + 'scm.diffDecorationsGutterWidth': { + type: 'number', + enum: [1, 2, 3, 4, 5], + default: 3, + description: localize('diffGutterWidth', "Controls the width(px) of diff decorations in gutter (added & modified).") + }, + 'scm.diffDecorationsGutterVisibility': { + type: 'string', + enum: ['always', 'hover'], + enumDescriptions: [ + localize('scm.diffDecorationsGutterVisibility.always', "Show the diff decorator in the gutter at all times."), + localize('scm.diffDecorationsGutterVisibility.hover', "Show the diff decorator in the gutter only on hover.") + ], + description: localize('scm.diffDecorationsGutterVisibility', "Controls the visibility of the Source Control diff decorator in the gutter."), + default: 'always' + }, + 'scm.diffDecorationsGutterAction': { + type: 'string', + enum: ['diff', 'none'], + enumDescriptions: [ + localize('scm.diffDecorationsGutterAction.diff', "Show the inline diff Peek view on click."), + localize('scm.diffDecorationsGutterAction.none', "Do nothing.") + ], + description: localize('scm.diffDecorationsGutterAction', "Controls the behavior of Source Control diff gutter decorations."), + default: 'diff' + }, + 'scm.diffDecorationsGutterPattern': { + type: 'object', + description: localize('diffGutterPattern', "Controls whether a pattern is used for the diff decorations in gutter."), + additionalProperties: false, + properties: { + 'added': { + type: 'boolean', + description: localize('diffGutterPatternAdded', "Use pattern for the diff decorations in gutter for added lines."), + }, + 'modified': { + type: 'boolean', + description: localize('diffGutterPatternModifed', "Use pattern for the diff decorations in gutter for modified lines."), + }, + }, + default: { + 'added': false, + 'modified': true + } + }, + 'scm.diffDecorationsIgnoreTrimWhitespace': { + type: 'string', + enum: ['true', 'false', 'inherit'], + enumDescriptions: [ + localize('scm.diffDecorationsIgnoreTrimWhitespace.true', "Ignore leading and trailing whitespace."), + localize('scm.diffDecorationsIgnoreTrimWhitespace.false', "Do not ignore leading and trailing whitespace."), + localize('scm.diffDecorationsIgnoreTrimWhitespace.inherit', "Inherit from `diffEditor.ignoreTrimWhitespace`.") + ], + description: localize('diffDecorationsIgnoreTrimWhitespace', "Controls whether leading and trailing whitespace is ignored in Source Control diff gutter decorations."), + default: 'false' + }, + 'scm.alwaysShowActions': { + type: 'boolean', + description: localize('alwaysShowActions', "Controls whether inline actions are always visible in the Source Control view."), + default: false + }, + 'scm.countBadge': { + type: 'string', + enum: ['all', 'focused', 'off'], + enumDescriptions: [ + localize('scm.countBadge.all', "Show the sum of all Source Control Provider count badges."), + localize('scm.countBadge.focused', "Show the count badge of the focused Source Control Provider."), + localize('scm.countBadge.off', "Disable the Source Control count badge.") + ], + description: localize('scm.countBadge', "Controls the count badge on the Source Control icon on the Activity Bar."), + default: 'all' + }, + 'scm.providerCountBadge': { + type: 'string', + enum: ['hidden', 'auto', 'visible'], + enumDescriptions: [ + localize('scm.providerCountBadge.hidden', "Hide Source Control Provider count badges."), + localize('scm.providerCountBadge.auto', "Only show count badge for Source Control Provider when non-zero."), + localize('scm.providerCountBadge.visible', "Show Source Control Provider count badges.") + ], + markdownDescription: localize('scm.providerCountBadge', "Controls the count badges on Source Control Provider headers. These headers appear in the Source Control view when there is more than one provider or when the {0} setting is enabled, and in the Source Control Repositories view.", '\`#scm.alwaysShowRepositories#\`'), + default: 'hidden' + }, + 'scm.defaultViewMode': { + type: 'string', + enum: ['tree', 'list'], + enumDescriptions: [ + localize('scm.defaultViewMode.tree', "Show the repository changes as a tree."), + localize('scm.defaultViewMode.list', "Show the repository changes as a list.") + ], + description: localize('scm.defaultViewMode', "Controls the default Source Control repository view mode."), + default: 'list' + }, + 'scm.defaultViewSortKey': { + type: 'string', + enum: ['name', 'path', 'status'], + enumDescriptions: [ + localize('scm.defaultViewSortKey.name', "Sort the repository changes by file name."), + localize('scm.defaultViewSortKey.path', "Sort the repository changes by path."), + localize('scm.defaultViewSortKey.status', "Sort the repository changes by Source Control status.") + ], + description: localize('scm.defaultViewSortKey', "Controls the default Source Control repository changes sort order when viewed as a list."), + default: 'path' + }, + 'scm.autoReveal': { + type: 'boolean', + description: localize('autoReveal', "Controls whether the Source Control view should automatically reveal and select files when opening them."), + default: true + }, + 'scm.inputFontFamily': { + type: 'string', + markdownDescription: localize('inputFontFamily', "Controls the font for the input message. Use `default` for the workbench user interface font family, `editor` for the `#editor.fontFamily#`'s value, or a custom font family."), + default: 'default' + }, + 'scm.inputFontSize': { + type: 'number', + markdownDescription: localize('inputFontSize', "Controls the font size for the input message in pixels."), + default: 13 + }, + 'scm.inputMaxLineCount': { + type: 'number', + markdownDescription: localize('inputMaxLines', "Controls the maximum number of lines that the input will auto-grow to."), + minimum: 1, + maximum: 50, + default: 10 + }, + 'scm.inputMinLineCount': { + type: 'number', + markdownDescription: localize('inputMinLines', "Controls the minimum number of lines that the input will auto-grow from."), + minimum: 1, + maximum: 50, + default: 1 + }, + 'scm.alwaysShowRepositories': { + type: 'boolean', + markdownDescription: localize('alwaysShowRepository', "Controls whether repositories should always be visible in the Source Control view."), + default: false + }, + 'scm.repositories.sortOrder': { + type: 'string', + enum: ['discovery time', 'name', 'path'], + enumDescriptions: [ + localize('scm.repositoriesSortOrder.discoveryTime', "Repositories in the Source Control Repositories view are sorted by discovery time. Repositories in the Source Control view are sorted in the order that they were selected."), + localize('scm.repositoriesSortOrder.name', "Repositories in the Source Control Repositories and Source Control views are sorted by repository name."), + localize('scm.repositoriesSortOrder.path', "Repositories in the Source Control Repositories and Source Control views are sorted by repository path.") + ], + description: localize('repositoriesSortOrder', "Controls the sort order of the repositories in the source control repositories view."), + default: 'discovery time' + }, + 'scm.repositories.visible': { + type: 'number', + description: localize('providersVisible', "Controls how many repositories are visible in the Source Control Repositories section. Set to 0, to be able to manually resize the view."), + default: 10 + }, + 'scm.repositories.selectionMode': { + type: 'string', + enum: ['multiple', 'single'], + enumDescriptions: [ + localize('scm.repositories.selectionMode.multiple', "Multiple repositories can be selected at the same time."), + localize('scm.repositories.selectionMode.single', "Only one repository can be selected at a time.") + ], + description: localize('scm.repositories.selectionMode', "Controls the selection mode of the repositories in the Source Control Repositories view."), + default: 'multiple' + }, + 'scm.repositories.explorer': { + type: 'boolean', + markdownDescription: localize('scm.repositories.explorer', "Controls whether to show repository artifacts in the Source Control Repositories view. This feature is experimental and only works when {0} is set to `{1}`.", '\`#scm.repositories.selectionMode#\`', 'single'), + default: false, + tags: ['experimental'] + }, + 'scm.showActionButton': { + type: 'boolean', + markdownDescription: localize('showActionButton', "Controls whether an action button can be shown in the Source Control view."), + default: true + }, + 'scm.showInputActionButton': { + type: 'boolean', + markdownDescription: localize('showInputActionButton', "Controls whether an action button can be shown in the Source Control input."), + default: true + }, + 'scm.workingSets.enabled': { + type: 'boolean', + description: localize('scm.workingSets.enabled', "Controls whether to store editor working sets when switching between source control history item groups."), + default: false + }, + 'scm.workingSets.default': { + type: 'string', + enum: ['empty', 'current'], + enumDescriptions: [ + localize('scm.workingSets.default.empty', "Use an empty working set when switching to a source control history item group that does not have a working set."), + localize('scm.workingSets.default.current', "Use the current working set when switching to a source control history item group that does not have a working set.") + ], + description: localize('scm.workingSets.default', "Controls the default working set to use when switching to a source control history item group that does not have a working set."), + default: 'current' + }, + 'scm.compactFolders': { + type: 'boolean', + description: localize('scm.compactFolders', "Controls whether the Source Control view should render folders in a compact form. In such a form, single child folders will be compressed in a combined tree element."), + default: true + }, + 'scm.graph.pageOnScroll': { + type: 'boolean', + description: localize('scm.graph.pageOnScroll', "Controls whether the Source Control Graph view will load the next page of items when you scroll to the end of the list."), + default: true + }, + 'scm.graph.pageSize': { + type: 'number', + description: localize('scm.graph.pageSize', "The number of items to show in the Source Control Graph view by default and when loading more items."), + minimum: 1, + maximum: 1000, + default: 50 + }, + 'scm.graph.badges': { + type: 'string', + enum: ['all', 'filter'], + enumDescriptions: [ + localize('scm.graph.badges.all', "Show badges of all history item groups in the Source Control Graph view."), + localize('scm.graph.badges.filter', "Show only the badges of history item groups used as a filter in the Source Control Graph view.") + ], + description: localize('scm.graph.badges', "Controls which badges are shown in the Source Control Graph view. The badges are shown on the right side of the graph indicating the names of history item groups."), + default: 'filter' + }, + 'scm.graph.showIncomingChanges': { + type: 'boolean', + description: localize('scm.graph.showIncomingChanges', "Controls whether to show incoming changes in the Source Control Graph view."), + default: true + }, + 'scm.graph.showOutgoingChanges': { + type: 'boolean', + description: localize('scm.graph.showOutgoingChanges', "Controls whether to show outgoing changes in the Source Control Graph view."), + default: true + } + } +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'scm.acceptInput', + metadata: { description: localize('scm accept', "Source Control: Accept Input"), args: [] }, + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.has('scmRepository'), + primary: KeyMod.CtrlCmd | KeyCode.Enter, + handler: accessor => { + const contextKeyService = accessor.get(IContextKeyService); + const context = contextKeyService.getContext(getActiveElement()); + const repositoryId = context.getValue('scmRepository'); + + if (!repositoryId) { + return Promise.resolve(null); + } + + const scmService = accessor.get(ISCMService); + const repository = scmService.getRepository(repositoryId); + + if (!repository?.provider.acceptInputCommand) { + return Promise.resolve(null); + } + + const id = repository.provider.acceptInputCommand.id; + const args = repository.provider.acceptInputCommand.arguments; + const commandService = accessor.get(ICommandService); + + return commandService.executeCommand(id, ...(args || [])); + } +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'scm.clearValidation', + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and( + ContextKeyExpr.has('scmRepository'), + SCMInputContextKeys.SCMInputHasValidationMessage), + primary: KeyCode.Escape, + handler: async (accessor) => { + const scmViewService = accessor.get(ISCMViewService); + scmViewService.activeRepository.get()?.repository.input.clearValidation(); + } +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'scm.clearInput', + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and( + ContextKeyExpr.has('scmRepository'), + SuggestContext.Visible.toNegated(), + InlineCompletionContextKeys.inlineSuggestionVisible.toNegated(), + SCMInputContextKeys.SCMInputHasValidationMessage.toNegated(), + EditorContextKeys.hasNonEmptySelection.toNegated()), + primary: KeyCode.Escape, + handler: async (accessor) => { + const scmService = accessor.get(ISCMService); + const contextKeyService = accessor.get(IContextKeyService); + + const context = contextKeyService.getContext(getActiveElement()); + const repositoryId = context.getValue('scmRepository'); + const repository = repositoryId ? scmService.getRepository(repositoryId) : undefined; + repository?.input.setValue('', true); + } +}); + +const viewNextCommitCommand = { + description: { description: localize('scm view next commit', "Source Control: View Next Commit"), args: [] }, + weight: KeybindingWeight.WorkbenchContrib, + handler: (accessor: ServicesAccessor) => { + const contextKeyService = accessor.get(IContextKeyService); + const scmService = accessor.get(ISCMService); + const context = contextKeyService.getContext(getActiveElement()); + const repositoryId = context.getValue('scmRepository'); + const repository = repositoryId ? scmService.getRepository(repositoryId) : undefined; + repository?.input.showNextHistoryValue(); + } +}; + +const viewPreviousCommitCommand = { + description: { description: localize('scm view previous commit', "Source Control: View Previous Commit"), args: [] }, + weight: KeybindingWeight.WorkbenchContrib, + handler: (accessor: ServicesAccessor) => { + const contextKeyService = accessor.get(IContextKeyService); + const scmService = accessor.get(ISCMService); + const context = contextKeyService.getContext(getActiveElement()); + const repositoryId = context.getValue('scmRepository'); + const repository = repositoryId ? scmService.getRepository(repositoryId) : undefined; + repository?.input.showPreviousHistoryValue(); + } +}; + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + ...viewNextCommitCommand, + id: 'scm.viewNextCommit', + when: ContextKeyExpr.and(ContextKeyExpr.has('scmRepository'), ContextKeyExpr.has('scmInputIsInLastPosition'), SuggestContext.Visible.toNegated()), + primary: KeyCode.DownArrow +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + ...viewPreviousCommitCommand, + id: 'scm.viewPreviousCommit', + when: ContextKeyExpr.and(ContextKeyExpr.has('scmRepository'), ContextKeyExpr.has('scmInputIsInFirstPosition'), SuggestContext.Visible.toNegated()), + primary: KeyCode.UpArrow +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + ...viewNextCommitCommand, + id: 'scm.forceViewNextCommit', + when: ContextKeyExpr.has('scmRepository'), + primary: KeyMod.Alt | KeyCode.DownArrow +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + ...viewPreviousCommitCommand, + id: 'scm.forceViewPreviousCommit', + when: ContextKeyExpr.has('scmRepository'), + primary: KeyMod.Alt | KeyCode.UpArrow +}); + +CommandsRegistry.registerCommand('scm.openInIntegratedTerminal', async (accessor, ...providers: ISCMProvider[]) => { + if (!providers || providers.length === 0) { + return; + } + + const commandService = accessor.get(ICommandService); + const listService = accessor.get(IListService); + + let provider = providers.length === 1 ? providers[0] : undefined; + + if (!provider) { + const list = listService.lastFocusedList; + const element = list?.getHTMLElement(); + + if (list instanceof WorkbenchList && element && isActiveElement(element)) { + const [index] = list.getFocus(); + const focusedElement = list.element(index); + + // Source Control Repositories + if (isSCMRepository(focusedElement)) { + provider = focusedElement.provider; + } + } + } + + if (!provider?.rootUri) { + return; + } + + await commandService.executeCommand('openInIntegratedTerminal', provider.rootUri); +}); + +CommandsRegistry.registerCommand('scm.openInTerminal', async (accessor, provider: ISCMProvider) => { + if (!provider || !provider.rootUri) { + return; + } + + const commandService = accessor.get(ICommandService); + await commandService.executeCommand('openInTerminal', provider.rootUri); +}); + +CommandsRegistry.registerCommand('scm.setActiveProvider', async (accessor) => { + const instantiationService = accessor.get(IInstantiationService); + const scmViewService = accessor.get(ISCMViewService); + + const placeHolder = localize('scmActiveRepositoryPlaceHolder', "Select the active repository, type to filter all repositories"); + const autoQuickItemDescription = localize('scmActiveRepositoryAutoDescription', "The active repository is updated based on active editor"); + const repositoryPicker = instantiationService.createInstance(RepositoryPicker, placeHolder, autoQuickItemDescription); + + const result = await repositoryPicker.pickRepository(); + if (result?.repository) { + const repository = result.repository !== 'auto' ? result.repository : undefined; + scmViewService.pinActiveRepository(repository); + } +}); + +MenuRegistry.appendMenuItem(MenuId.SCMSourceControl, { + group: '99_terminal', + command: { + id: 'scm.openInTerminal', + title: localize('open in external terminal', "Open in External Terminal") + }, + when: ContextKeyExpr.and( + RemoteNameContext.isEqualTo(''), + ContextKeyExpr.equals('scmProviderHasRootUri', true), + ContextKeyExpr.or( + ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'external'), + ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'both'))) +}); + +MenuRegistry.appendMenuItem(MenuId.SCMSourceControl, { + group: '99_terminal', + command: { + id: 'scm.openInIntegratedTerminal', + title: localize('open in integrated terminal', "Open in Integrated Terminal") + }, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('scmProviderHasRootUri', true), + ContextKeyExpr.or( + ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'integrated'), + ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'both'))) +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'workbench.scm.action.focusPreviousInput', + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeys.RepositoryVisibilityCount.notEqualsTo(0), + handler: async accessor => { + const viewsService = accessor.get(IViewsService); + const scmView = await viewsService.openView(VIEW_PANE_ID); + if (scmView) { + scmView.focusPreviousInput(); + } + } +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'workbench.scm.action.focusNextInput', + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeys.RepositoryVisibilityCount.notEqualsTo(0), + handler: async accessor => { + const viewsService = accessor.get(IViewsService); + const scmView = await viewsService.openView(VIEW_PANE_ID); + if (scmView) { + scmView.focusNextInput(); + } + } +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'workbench.scm.action.focusPreviousResourceGroup', + weight: KeybindingWeight.WorkbenchContrib, + handler: async accessor => { + const viewsService = accessor.get(IViewsService); + const scmView = await viewsService.openView(VIEW_PANE_ID); + if (scmView) { + scmView.focusPreviousResourceGroup(); + } + } +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'workbench.scm.action.focusNextResourceGroup', + weight: KeybindingWeight.WorkbenchContrib, + handler: async accessor => { + const viewsService = accessor.get(IViewsService); + const scmView = await viewsService.openView(VIEW_PANE_ID); + if (scmView) { + scmView.focusNextResourceGroup(); + } + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'scm.editor.triggerSetup', + title: localize('scmEditorResolveMergeConflict', "Resolve Conflicts with AI"), + icon: Codicon.chatSparkle, + f1: false, + menu: { + id: MenuId.EditorContent, + when: ContextKeyExpr.and( + ChatContextKeys.Setup.hidden.negate(), + ChatContextKeys.Setup.disabledInWorkspace.negate(), + ChatContextKeys.Setup.completed.negate(), + ContextKeyExpr.in(ResourceContextKey.Resource.key, 'git.mergeChanges'), + ContextKeyExpr.equals('git.activeResourceHasMergeConflicts', true) + ) + } + }); + } + + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const commandService = accessor.get(ICommandService); + + const result = await commandService.executeCommand(CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID); + if (!result) { + return; + } + + const command = product.defaultChatAgent?.resolveMergeConflictsCommand; + if (!command) { + return; + } + + await commandService.executeCommand(command, ...args); + } +}); + +AccessibleViewRegistry.register(new SCMAccessibilityHelp()); diff --git a/webpack.config.js b/webpack.config.js index cab06fade..1edba9575 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -46,6 +46,7 @@ const devVscodeStatic = [ export default (env, argv) => { const devMode = argv.mode === 'development'; const devVscode = !!process.env.DEV_VSCODE; + const ciGithubProxy = devMode && process.env.CI === 'true' && !!process.env.GITHUB_TOKEN; const minifyCSS = (code) => (devMode ? code : new CleanCSS().minify(code).styles); const minifyJS = (code) => (devMode ? code : UglifyJS.minify(code).code); const availableLanguages = devVscode ? [] : fs.readdirSync(path.join(vscodeWebPath, 'nls')); @@ -94,24 +95,31 @@ export default (env, argv) => { ], performance: false, devServer: { + host: ciGithubProxy ? 'localhost' : undefined, port: 8080, proxy: [ { - context: ['/api/github/search/code'], + // Used by code search and by CI builds that point GITHUB_API_PREFIX here. + context: (pathname, req) => pathname.startsWith('/api/github/'), target: 'https://api.github.com', changeOrigin: true, + followRedirects: false, pathRewrite: { '^/api/github': '' }, headers: { 'user-agent': 'GitHub1s' }, on: { proxyReq: (proxyReq) => { proxyReq.removeHeader('cookie'); proxyReq.removeHeader('origin'); + if (ciGithubProxy) { + // Set up credentials for the CI environment to avoid GitHub API rate limits. + proxyReq.setHeader('authorization', `Bearer ${process.env.GITHUB_TOKEN}`); + } }, }, }, ], liveReload: false, - allowedHosts: 'all', + allowedHosts: ciGithubProxy ? 'auto' : 'all', client: { overlay: false }, devMiddleware: { writeToDisk: true }, static: devVscode ? devVscodeStatic : [], diff --git a/workers/discovery/README.md b/workers/discovery/README.md index a108bc4e0..cd74271de 100644 --- a/workers/discovery/README.md +++ b/workers/discovery/README.md @@ -1,3 +1,15 @@ # Repository Discovery Snapshots This Worker collects public repositories from GitHub Search on a schedule, stores complete JSON snapshots in Workers KV, and exposes a public endpoint for reading the latest snapshot. + +## Preview collection locally + +From the repository root, provide `GITHUB_TOKEN` in the process environment and run: + +```bash +npm run discovery +``` + +The command writes `out/discovery/github/latest.json` using the same collection logic as the Worker. It does not publish to KV. It accepts no command-line arguments; edit [`src/collections.ts`](src/collections.ts) to change queries. + +The collections cover new projects, recently updated projects with fewer stars, highly starred projects, and selected topics. Queries and per-collection limits are defined in that file; the collection names describe those filters rather than a measure of recent star growth. diff --git a/workers/discovery/src/snapshots.ts b/workers/discovery/src/snapshots.ts index 8e57aacbc..8162eb5f0 100644 --- a/workers/discovery/src/snapshots.ts +++ b/workers/discovery/src/snapshots.ts @@ -1,6 +1,6 @@ import type { DiscoverySnapshot } from './types.ts'; -export const LATEST_KEY = 'latest'; +export const LATEST_KEY = 'github:latest'; export const SNAPSHOT_RETENTION_SECONDS = 90 * 24 * 60 * 60; // Use the scheduled UTC date so execution delays cannot change the archive day.