├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md └── workflows │ ├── README.md │ ├── lock-threads.yml │ ├── vsce-package.yml │ └── vsce-publish.yml ├── .gitignore ├── .husky └── commit-msg ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── data ├── README.md ├── bibtex │ └── snippets.json └── latex │ ├── bibliography-styles.json │ ├── class-names.json │ ├── environments.json │ ├── package-names.json │ └── top-hundred-snippets.json ├── docs ├── README.md ├── anatomy.md ├── assets │ ├── demo01-login.gif │ ├── demo02-compile.gif │ ├── demo03-synctex.gif │ ├── demo04-dark-mode.gif │ ├── demo05-intellisense.png │ ├── demo06-chat.gif │ ├── demo07-local.gif │ ├── login_with_cookie.png │ ├── screenshot-add-server-button.png │ ├── screenshot-add-server-inputbox.png │ ├── screenshot-advance-chat.png │ ├── screenshot-change-compiler.png │ ├── screenshot-chat-view.png │ ├── screenshot-commands-and-shortcuts.png │ ├── screenshot-compile-output.png │ ├── screenshot-compile-status.png │ ├── screenshot-config-local-replica.png │ ├── screenshot-create-local-replica.png │ ├── screenshot-create-new-project.png │ ├── screenshot-document-outline.png │ ├── screenshot-enter-invisible-mode.png │ ├── screenshot-formatter-linebreak.png │ ├── screenshot-invisible-mode-status.png │ ├── screenshot-line-reference.png │ ├── screenshot-local-replica-status.png │ ├── screenshot-login-to-server.png │ ├── screenshot-logout-from-server.png │ ├── screenshot-online-collaborators.png │ ├── screenshot-open-project.png │ ├── screenshot-pro-import-external-file.png │ ├── screenshot-pro-refresh-external-file.png │ ├── screenshot-project-history-diff.png │ ├── screenshot-project-history.png │ ├── screenshot-project-tag-management.png │ ├── screenshot-refresh-project-list.png │ ├── screenshot-setup-local-replica-folder.png │ ├── screenshot-spell-check-suggestions.png │ ├── screenshot-spell-check.png │ └── screenshot-vscode-configurations.png ├── webapi.md └── wiki.md ├── l10n └── bundle.l10n.json ├── package-lock.json ├── package.json ├── package.nls.json ├── patches └── socket.io-client+0.9.17-overleaf-5.patch ├── resources └── icons │ ├── app_icon.png │ └── overleaf_bw.svg ├── src ├── api │ ├── base.ts │ ├── extendedBase.ts │ ├── socketio.ts │ └── socketioAlt.ts ├── collaboration │ ├── chatViewProvider.ts │ └── clientManager.ts ├── compile │ ├── compileLogParser.ts │ └── compileManager.ts ├── consts.ts ├── core │ ├── pdfViewEditorProvider.ts │ ├── projectManagerProvider.ts │ └── remoteFileSystemProvider.ts ├── extension.ts ├── intellisense │ ├── index.ts │ ├── langCompletionProvider.ts │ ├── langIntellisenseProvider.ts │ ├── langMisspellingCheckProvider.ts │ ├── texDocumentFormatProvider.ts │ ├── texDocumentParseUtility.ts │ └── texDocumentSymbolProvider.ts ├── scm │ ├── historyViewProvider.ts │ ├── index.ts │ ├── localGitBridgeSCM.ts │ ├── localReplicaSCM.ts │ └── scmCollectionProvider.ts └── utils │ ├── eventBus.ts │ └── globalStateManager.ts ├── tsconfig.json └── views ├── chat-view ├── .gitignore ├── env.d.ts ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── App.vue │ ├── components │ │ ├── InputBox.vue │ │ ├── MessageItem.vue │ │ ├── MessageList.vue │ │ ├── NewMessageNotice.vue │ │ └── QuickReply.vue │ ├── main.ts │ ├── utils.ts │ └── vscode.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── pdf-viewer ├── index.css ├── index.js └── vendor └── pdfjs-3.10.111-dist.patch /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/naming-convention": "warn", 13 | "@typescript-eslint/semi": "warn", 14 | "curly": "warn", 15 | "eqeqeq": "warn", 16 | "no-throw-literal": "warn", 17 | "semi": "off" 18 | }, 19 | "ignorePatterns": [ 20 | "out", 21 | "dist", 22 | "**/*.d.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the bug 11 | > A clear and concise description of what the bug is. 12 | 13 | 14 | ### Expected behavior 15 | > A clear and concise description of what you expected to happen. 16 | 17 | 18 | ### How To Reproduce 19 | > Detailed steps to reproduce the behavior. 20 | 21 | 22 | ### Environment 23 | - **Overleaf Workshop Extension version**: [e.g., 0.7.0] 24 | - **VS Code version**: [e.g. 1.85.0] 25 | - **Overleaf Edition**: Official / Community / Enterprise (Server Pro) 26 | 27 | 28 | ### [Optional] Developer Logs 29 | > If applicable, please paste the logs inside the details section below. 30 | > Please find the logs via: Title bar **"Help"** > **"Toggle Developer Tools"** > **"Console"**. 31 | 32 |
33 | ... 34 |
35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this extension 4 | title: "[Feature Request]" 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Disclaimer 11 | > Please make sure all the items below is checked (i.e., prefixed with "- [x]") 12 | - [ ] The request feature has not been addressed in the [feature request list](https://github.com/iamhyc/Overleaf-Workshop/issues?q=is%3Aissue+label%3A%22feature+request%22+). 13 | - [ ] The design of the feature does not require detailed discussion, or existing discussion with drawn conclusion can be found in [Discussions Area](https://github.com/iamhyc/Overleaf-Workshop/discussions) at: [paste the link here] 14 | 15 | ### Motivation 16 | > Please give strong enough reason(s) why the feature is demanded. 17 | - 18 | - 19 | 20 | ### Feature Description 21 | > A clear and concise description of what you want to happen under what circumstance. 22 | - 23 | - 24 | 25 | ### [Optional] Additional context 26 | > Please strike out any inappropriate items below. If you are not aware of the difference, please ignore this section. 27 | - **Applicable Overleaf Edition**: Official / Community / Enterprise (Server Pro) 28 | - **Applicable Extension Range**: Desktop Extension / Web Extension 29 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # Github Actions Overview 2 | 3 | We use Github Actions to automate various tasks in this repository. The following workflows are currently enabled: 4 | 5 | - **lock-threads.yml**: Locks closed issues and pull requests after a period of inactivity. 6 | 7 | - **vsce-package.yml**: Call `vsce package` to create a temporary VSCode extension package when a Pull Request is created/updated on the `master` branch. 8 | 9 | - **vsce-publish.yml**: Call `vsce publish` to publish the VSCode extension to the VSCode Marketplace when version tag is pushed on the `master` branch. 10 | -------------------------------------------------------------------------------- /.github/workflows/lock-threads.yml: -------------------------------------------------------------------------------- 1 | name: 'Schedule: Lock Inactive Threads' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | discussions: write 12 | 13 | concurrency: 14 | group: lock-threads 15 | 16 | jobs: 17 | action: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: dessant/lock-threads@v5 21 | with: 22 | issue-inactive-days: '30' 23 | pr-inactive-days: '30' 24 | log-output: true 25 | -------------------------------------------------------------------------------- /.github/workflows/vsce-package.yml: -------------------------------------------------------------------------------- 1 | name: Package Extension 2 | on: 3 | pull_request: 4 | branches: [master] 5 | types: [opened, synchronize, reopened] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: '20' 19 | 20 | - name: Install dependencies 21 | run: | 22 | npm install 23 | cd views/chat-view 24 | npm install 25 | 26 | - name: Install vsce 27 | run: npm install -g vsce 28 | 29 | - name: Bump version and package 30 | id: bump 31 | run: | 32 | npm version patch -no-git-tag-version 33 | COMMIT_SHA=$(git rev-parse --short HEAD) 34 | NEW_VERSION=$(jq -r '.version' package.json) 35 | NEW_VERSION_WITH_SHA="${NEW_VERSION}-${COMMIT_SHA}" 36 | jq --arg version "$NEW_VERSION_WITH_SHA" '.version = $version' package.json > temp.json && mv temp.json package.json 37 | echo "NEW_VERSION=$NEW_VERSION_WITH_SHA" >> $GITHUB_ENV 38 | vsce package 39 | 40 | - name: Upload artifact 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: overleaf-workshop-${{ env.NEW_VERSION }}.vsix 44 | path: ./*.vsix 45 | -------------------------------------------------------------------------------- /.github/workflows/vsce-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Extension 2 | on: 3 | push: 4 | branches: [master] 5 | 6 | jobs: 7 | publish: 8 | if: startsWith(github.ref, 'refs/tags/v') 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | with: 15 | ref: ${{ github.ref }} 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '20' 21 | 22 | - name: Install dependencies 23 | run: | 24 | npm install 25 | cd views/chat-view 26 | npm install 27 | 28 | - name: Install vsce 29 | run: npm install -g vsce 30 | 31 | - name: Publish 32 | env: 33 | VSCE_PAT: ${{ secrets.VSCE_PAT }} 34 | run: vsce publish -p $VSCE_PAT 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/settings.json 2 | 3 | node_modules 4 | out 5 | *.vsix 6 | 7 | **/vendor/** 8 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/out/**/*.js" 17 | ], 18 | "preLaunchTask": "${defaultBuildTask}" 19 | }, 20 | { 21 | "name": "Extension Tests", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "args": [ 25 | "--extensionDevelopmentPath=${workspaceFolder}", 26 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 27 | ], 28 | "outFiles": [ 29 | "${workspaceFolder}/out/test/**/*.js" 30 | ], 31 | "preLaunchTask": "${defaultBuildTask}" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | src/** 4 | patches/** 5 | docs/** 6 | .gitignore 7 | .yarnrc 8 | **/tsconfig.json 9 | **/.eslintrc.json 10 | **/*.map 11 | **/*.ts 12 | 13 | data/vendor/** 14 | !data/vendor/LICENSE* 15 | !data/vendor/README.md 16 | !data/vendor/languages/** 17 | 18 | views/pdf-viewer/vendor/web/*.pdf 19 | views/pdf-viewer/vendor/web/cmaps 20 | views/pdf-viewer/vendor/web/locale 21 | views/pdf-viewer/vendor/web/standard_fonts 22 | 23 | views/chat-view/** 24 | !views/chat-view/dist/** 25 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Citizen Code of Conduct 2 | 3 | ## 1. Purpose 4 | 5 | A primary goal of Overleaf Workshop is to be inclusive to the largest number of contributors, with the most varied and diverse backgrounds possible. As such, we are committed to providing a friendly, safe and welcoming environment for all, regardless of gender, sexual orientation, ability, ethnicity, socioeconomic status, and religion (or lack thereof). 6 | 7 | This code of conduct outlines our expectations for all those who participate in our community, as well as the consequences for unacceptable behavior. 8 | 9 | We invite all those who participate in Overleaf Workshop to help us create safe and positive experiences for everyone. 10 | 11 | ## 2. Open [Source/Culture/Tech] Citizenship 12 | 13 | A supplemental goal of this Code of Conduct is to increase open [source/culture/tech] citizenship by encouraging participants to recognize and strengthen the relationships between our actions and their effects on our community. 14 | 15 | Communities mirror the societies in which they exist and positive action is essential to counteract the many forms of inequality and abuses of power that exist in society. 16 | 17 | If you see someone who is making an extra effort to ensure our community is welcoming, friendly, and encourages all participants to contribute to the fullest extent, we want to know. 18 | 19 | ## 3. Expected Behavior 20 | 21 | The following behaviors are expected and requested of all community members: 22 | 23 | * Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community. 24 | * Exercise consideration and respect in your speech and actions. 25 | * Attempt collaboration before conflict. 26 | * Refrain from demeaning, discriminatory, or harassing behavior and speech. 27 | * Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential. 28 | * Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations. 29 | 30 | ## 4. Unacceptable Behavior 31 | 32 | The following behaviors are considered harassment and are unacceptable within our community: 33 | 34 | * Violence, threats of violence or violent language directed against another person. 35 | * Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language. 36 | * Posting or displaying sexually explicit or violent material. 37 | * Posting or threatening to post other people's personally identifying information ("doxing"). 38 | * Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability. 39 | * Inappropriate photography or recording. 40 | * Inappropriate physical contact. You should have someone's consent before touching them. 41 | * Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances. 42 | * Deliberate intimidation, stalking or following (online or in person). 43 | * Advocating for, or encouraging, any of the above behavior. 44 | * Sustained disruption of community events, including talks and presentations. 45 | 46 | ## 5. Weapons Policy 47 | 48 | No weapons will be allowed at Overleaf Workshop events, community spaces, or in other spaces covered by the scope of this Code of Conduct. Weapons include but are not limited to guns, explosives (including fireworks), and large knives such as those used for hunting or display, as well as any other item used for the purpose of causing injury or harm to others. Anyone seen in possession of one of these items will be asked to leave immediately, and will only be allowed to return without the weapon. Community members are further expected to comply with all state and local laws on this matter. 49 | 50 | ## 6. Consequences of Unacceptable Behavior 51 | 52 | Unacceptable behavior from any community member, including sponsors and those with decision-making authority, will not be tolerated. 53 | 54 | Anyone asked to stop unacceptable behavior is expected to comply immediately. 55 | 56 | If a community member engages in unacceptable behavior, the community organizers may take any action they deem appropriate, up to and including a temporary ban or permanent expulsion from the community without warning (and without refund in the case of a paid event). 57 | 58 | ## 7. Reporting Guidelines 59 | 60 | If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a community organizer as soon as possible. 61 | 62 | 63 | 64 | Additionally, community organizers are available to help community members engage with local law enforcement or to otherwise help those experiencing unacceptable behavior feel safe. In the context of in-person events, organizers will also provide escorts as desired by the person experiencing distress. 65 | 66 | ## 8. Addressing Grievances 67 | 68 | If you feel you have been falsely or unfairly accused of violating this Code of Conduct, you should notify the project maintainer with a concise description of your grievance. Your grievance will be handled in accordance with our existing governing policies. 69 | 70 | 71 | 72 | ## 9. Scope 73 | 74 | We expect all community participants (contributors, paid or otherwise; sponsors; and other guests) to abide by this Code of Conduct in all community venues--online and in-person--as well as in all one-on-one communications pertaining to community business. 75 | 76 | This code of conduct and its related procedures also applies to unacceptable behavior occurring outside the scope of community activities when such behavior has the potential to adversely affect the safety and well-being of community members. 77 | 78 | ## 10. Contact info 79 | 80 | [iamhyc](mailto:sudofree__at__163_com) 81 | 82 | ## 11. License and attribution 83 | 84 | The Citizen Code of Conduct is distributed by [Stumptown Syndicate](http://stumptownsyndicate.org) under a [Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/). 85 | 86 | Portions of text derived from the [Django Code of Conduct](https://www.djangoproject.com/conduct/) and the [Geek Feminism Anti-Harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy). 87 | 88 | _Revision 2.3. Posted 6 March 2017._ 89 | 90 | _Revision 2.2. Posted 4 February 2016._ 91 | 92 | _Revision 2.1. Posted 23 June 2014._ 93 | 94 | _Revision 2.0, adopted by the [Stumptown Syndicate](http://stumptownsyndicate.org) board on 10 January 2013. Posted 17 March 2013._ 95 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Overleaf Workshop 2 | The Overleaf Workshop extension is an open source project and we welcome contributions of all kinds from the community. 3 | There are many ways to contribute, from improving the documentation, submitting bug reports and feature requests or writing code which can be incorporated into the extension itself. 4 | 5 | In this document, we will mainly elaborate on how to make code contributions to the project. 6 | 7 | ## Contribution Guidance 8 | 9 | > [!WARNING] 10 | > We will not accept any pull request that is not associated with an issue. 11 | > If you have any question about the project, please create an issue or discuss it in the [Discussions](https://github.com/iamhyc/Overleaf-Workshop/discussions) section. 12 | 13 | To make a contribution to this project, please follow the steps below: 14 | 15 | 1. Create or find an `Bug Report` or `Feature Request` issue on [GitHub](https://github.com/iamhyc/Overleaf-Workshop/issues). 16 | 17 | If you are creating a new issue, please make sure that it is not a duplicate of an existing issue. 18 | 19 | 2. Fork this repository and create a new branch from `master` branch. 20 | 21 | It is recommended to name the branch with the issue number, e.g., `issue-123`, or related keywords, e.g., `fix-xxx`, `feat-xxx`. 22 | 23 | 3. Make your changes and commit them to the new branch. 24 | 25 | Please make sure that your commit messages follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. 26 | 27 | 4. Create a pull request to the `master` branch of this repository. 28 | 29 | Please make sure that the pull request is associated with the issue you are working on. If you do not know how to do this, please refer to [Linking a pull request to an issue](https://docs.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue). 30 | 31 | 5. Wait for the assigned reviewer to review your pull request. 32 | 33 | If there are any problems, please fix them and commit the changes to the same branch with rebase. If the reviewer approves your pull request, it will be merged into the `master` branch. 34 | 35 | 6. After the pull request is merged, the associated issue will be closed automatically. 36 | 37 | 38 | ## Development Guidance 39 | 40 | ### Prerequisites 41 | - [Node.js LTS](https://nodejs.org/en/) (>= 20.10.0) 42 | - Visual Studio Code (>= 1.80.0) 43 | > Recommended extensions: [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint), [JavaScript and TypeScript Nightly](https://marketplace.visualstudio.com/items?itemName=ms-vscode.vscode-typescript-next), [Vue Language Features (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.volar), [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) 44 | - Operating System with common Unix commands. 45 | > If you are using Windows, please refer to [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/install-win10), [Cygwin](https://www.cygwin.com/) or [Git Bash](https://gitforwindows.org/). 46 | > You can also use [gow](https://github.com/bmatzelle/gow) installed with [scoop](https://scoop.sh/). 47 | 48 | ### Build 49 | 50 | ```bash 51 | # Clone the Repository and Change Directory 52 | git clone https://github.com/iamhyc/Overleaf-Workshop.git 53 | cd Overleaf-Workshop 54 | 55 | # Install `vsce` globally 56 | npm install -g vsce # may require `sudo` on Linux 57 | 58 | # Install dependencies 59 | npm install 60 | cd views/chat-view && npm install && cd ../.. 61 | 62 | # Build the Extension 63 | npm run compile 64 | 65 | # [Optional] Package the Extension 66 | vsce package 67 | ``` 68 | 69 | ### Testing 70 | In VSCode, press F5 to start debugging. A new VSCode window will be opened with the extension loaded. 71 | 72 | ### Documentation 73 | - [VSCode Extension API](https://code.visualstudio.com/api/references/vscode-api) 74 | - [Overleaf Workshop Extension Documentation](https://github.com/iamhyc/Overleaf-Workshop/tree/master/docs/README.md) 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overleaf Workshop 2 | 3 | [![GitHub Repo stars](https://img.shields.io/github/stars/iamhyc/Overleaf-Workshop)](https://github.com/iamhyc/Overleaf-Workshop) 4 | [![version](https://img.shields.io/visual-studio-marketplace/v/iamhyc.overleaf-workshop)](https://marketplace.visualstudio.com/items?itemName=iamhyc.overleaf-workshop) 5 | [![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/iamhyc.overleaf-workshop)](https://marketplace.visualstudio.com/items?itemName=iamhyc.overleaf-workshop) 6 | [![updated](https://img.shields.io/visual-studio-marketplace/last-updated/iamhyc.overleaf-workshop)](https://marketplace.visualstudio.com/items?itemName=iamhyc.overleaf-workshop) 7 | [![release](https://img.shields.io/visual-studio-marketplace/release-date/iamhyc.overleaf-workshop)](https://vsmarketplacebadge.apphb.com/downloads-short/iamhyc.overleaf-workshop.svg) 8 | 9 | Open Overleaf (ShareLatex) projects in VSCode, with full collaboration support. 10 | 11 | ### User Guide 12 | 13 | The full user guide is available at [GitHub Wiki](https://github.com/iamhyc/Overleaf-Workshop/wiki). 14 | 15 | ### Features 16 | 17 | > [!NOTE] 18 | > For SSO login or captcha enabled servers like `https://www.overleaf.com`, please use "**Login with Cookies**" method. 19 | > For more details, please refer to [How to Login with Cookies](#how-to-login-with-cookies). 20 | 21 | - Login Server, Open Projects and Edit Files 22 | 23 | 24 | 25 | - On-the-fly Compiling and Previewing 26 | > Ctrl+Alt+B to compile, Ctrl+Alt+V preview. 27 | 28 | 29 | 30 | - SyncTeX and Reverse SyncTeX 31 | > Ctrl+Alt+J to jump to PDF. 32 | > Double click on PDF to jump to source code 33 | 34 | - Chat with Collaborators 35 | 36 | 37 | 38 | - Open Project Locally, Compile/Preview with [LaTeX-Workshop](https://github.com/James-Yu/LaTeX-Workshop) 39 | 40 | 41 | 42 | ### How to Login with Cookies 43 | 44 | 45 | 46 | In an already logged-in browser (Firefox for example): 47 | 48 | 1. Open "Developer Tools" (usually by pressing F12) and switch to the "Network" tab; 49 | 50 | Then, navigate to the Overleaf main page (e.g., `https://www.overleaf.com`) in the address bar. 51 | 52 | 2. Filter the listed items with `/project` and select the exact match. 53 | 54 | 3. Check the "Cookie" under "Request Headers" of the selected item and copy its value to login. 55 | > The format of the Cookie value would be like: `overleaf_session2=...` or `sharelatex.sid=...` 56 | 57 | ### Compatibility 58 | 59 | The following Overleaf (ShareLatex) Community Edition docker images provided on [Docker Hub](https://hub.docker.com/r/sharelatex/sharelatex) have been tested and verified to be compatible with this extension. 60 | 61 | - [x] [sharelatex/sharelatex:5.0.4](https://hub.docker.com/layers/sharelatex/sharelatex/5.0.4/images/sha256-429f6c4c02d5028172499aea347269220fb3505cbba2680f5c981057ffa59316?context=explore) (verified by [@Mingbo-Lee](https://github.com/Mingbo-Lee)) 62 | 63 | - [x] [sharelatex/sharelatex:4.2.4](https://hub.docker.com/layers/sharelatex/sharelatex/4.2.4/images/sha256-ac0fc6dbda5e82b9c979721773aa120ad3c4a63469b791b16c3711e0b937528c?context=explore) 64 | 65 | - [x] [sharelatex/sharelatex:4.1](https://hub.docker.com/layers/sharelatex/sharelatex/4.1/images/sha256-3798913f1ada2da8b897f6b021972db7874982b23bef162019a9ac57471bcee8?context=explore) (verified by [@iamhyc](https://github.com/iamhyc)) 66 | 67 | - [x] [sharelatex/sharelatex:3.5](https://hub.docker.com/layers/sharelatex/sharelatex/3.5/images/sha256-f97fa20e45cdbc688dc051cc4b0e0f4f91ae49fd12bded047d236ca389ad80ac?context=explore) (verified by [@iamhyc](https://github.com/iamhyc)) 68 | 69 | - [ ] [sharelatex/sharelatex:3.4](https://hub.docker.com/layers/sharelatex/sharelatex/3.4/images/sha256-2a72e9b6343ed66f37ded4e6da8df81ed66e8af77e553b91bd19307f98badc7a?context=explore) 70 | 71 | - [ ] [sharelatex/sharelatex:3.3](https://hub.docker.com/layers/sharelatex/sharelatex/3.3/images/sha256-e1ec01563d259bbf290de4eb90dce201147c0aae5a07738c8c2e538f6d39d3a8?context=explore) 72 | 73 | - [ ] [sharelatex/sharelatex:3.2](https://hub.docker.com/layers/sharelatex/sharelatex/3.2/images/sha256-5db71af296f7c16910f8e8939e3841dad8c9ac48ea0a807ad47ca690087f44bf?context=explore) 74 | 75 | - [ ] [sharelatex/sharelatex:3.1](https://hub.docker.com/layers/sharelatex/sharelatex/3.1/images/sha256-5b9de1e65257cea4682c1654af06408af7f9c0e2122952d6791cdda45705e84e?context=explore) 76 | 77 | - [ ] [sharelatex/sharelatex:3.0](https://hub.docker.com/layers/sharelatex/sharelatex/3.0/images/sha256-a36e54c66ef62fdee736ce2229289aa261b44f083a9fd553cf8264500612db27?context=explore) 78 | 79 | ### Development 80 | 81 | Please refer to the development guidance in [CONTRIBUTING.md](./CONTRIBUTING.md) 82 | 83 | ### References 84 | 85 | - [Overleaf Official Logos](https://www.overleaf.com/for/partners/logos) 86 | - [Overleaf Web Route List](./docs/webapi.md) 87 | - [James-Yu/LaTeX-Workshop](https://github.com/James-Yu/LaTeX-Workshop) 88 | - [jlelong/vscode-latex-basics](https://github.com/jlelong/vscode-latex-basics/tags) 89 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']}; -------------------------------------------------------------------------------- /data/README.md: -------------------------------------------------------------------------------- 1 | Static data manually extracted from "overleaf/services/web/frontend/js/features/source-editor/languages". -------------------------------------------------------------------------------- /data/bibtex/snippets.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "article", 4 | "requiredAttributes": [ 5 | "author", 6 | "title", 7 | "journal", 8 | "year" 9 | ] 10 | }, 11 | { 12 | "name": "book", 13 | "requiredAttributes": [ 14 | "author", 15 | "title", 16 | "publisher", 17 | "year" 18 | ] 19 | }, 20 | { 21 | "name": "booklet", 22 | "requiredAttributes": [ 23 | "key", 24 | "title" 25 | ] 26 | }, 27 | { 28 | "name": "conference", 29 | "requiredAttributes": [ 30 | "key", 31 | "title", 32 | "year" 33 | ] 34 | }, 35 | { 36 | "name": "inbook", 37 | "requiredAttributes": [ 38 | "author", 39 | "title", 40 | "publisher", 41 | "year", 42 | "chapter" 43 | ] 44 | }, 45 | { 46 | "name": "incollection", 47 | "requiredAttributes": [ 48 | "author", 49 | "title", 50 | "booktitle", 51 | "publisher", 52 | "year" 53 | ] 54 | }, 55 | { 56 | "name": "inproceedings", 57 | "requiredAttributes": [ 58 | "author", 59 | "title", 60 | "booktitle", 61 | "year" 62 | ] 63 | }, 64 | { 65 | "name": "manual", 66 | "requiredAttributes": [ 67 | "key", 68 | "title" 69 | ] 70 | }, 71 | { 72 | "name": "masterthesis", 73 | "requiredAttributes": [ 74 | "author", 75 | "title", 76 | "school", 77 | "year" 78 | ] 79 | }, 80 | { 81 | "name": "misc", 82 | "requiredAttributes": [ 83 | "key", 84 | "note" 85 | ] 86 | }, 87 | { 88 | "name": "phdthesis", 89 | "requiredAttributes": [ 90 | "author", 91 | "title", 92 | "school", 93 | "year" 94 | ] 95 | }, 96 | { 97 | "name": "proceedings", 98 | "requiredAttributes": [ 99 | "key", 100 | "title", 101 | "year" 102 | ] 103 | }, 104 | { 105 | "name": "techreport", 106 | "requiredAttributes": [ 107 | "author", 108 | "title", 109 | "institution", 110 | "year" 111 | ] 112 | }, 113 | { 114 | "name": "unpublished", 115 | "requiredAttributes": [ 116 | "author", 117 | "title", 118 | "note" 119 | ] 120 | } 121 | ] -------------------------------------------------------------------------------- /data/latex/bibliography-styles.json: -------------------------------------------------------------------------------- 1 | { 2 | "bibtex": [ 3 | "abbrv", 4 | "acm", 5 | "alpha", 6 | "apalike", 7 | "ieeetr", 8 | "plain", 9 | "siam", 10 | "unsrt" 11 | ], 12 | "natbib": [ 13 | "dinat", 14 | "plainnat", 15 | "abbrvnat", 16 | "unsrtnat", 17 | "rusnat", 18 | "ksfh_nat" 19 | ], 20 | "biblatex": [ 21 | "numeric", 22 | "alphabetic", 23 | "authoryear", 24 | "authortitle", 25 | "verbose", 26 | "reading", 27 | "draft", 28 | "authoryear-icomp", 29 | "apa", 30 | "bwl-FU", 31 | "chem-acs", 32 | "chem-angew", 33 | "chem-biochem", 34 | "chem-rsc", 35 | "ieee", 36 | "mla", 37 | "musuos", 38 | "nature", 39 | "nejm", 40 | "phys", 41 | "science", 42 | "geschichtsfrkl", 43 | "oscola" 44 | ], 45 | "biblatex-contrib": [] 46 | } -------------------------------------------------------------------------------- /data/latex/class-names.json: -------------------------------------------------------------------------------- 1 | [ 2 | "article", 3 | "report", 4 | "book", 5 | "letter", 6 | "slides", 7 | "beamer" 8 | ] 9 | -------------------------------------------------------------------------------- /data/latex/environments.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": [ 3 | "abstract", "align", "align*", 4 | "center", "document", "equation", "equation*", 5 | "gather", "gather*", 6 | "multline", "multline*", "quote", "split", 7 | "verbatim" 8 | ], 9 | "expanded": { 10 | "array": "\\begin{array}{${1:cc}}\n\t$2 & $3 \\\\\n\t$4 & $5\n\\end{array}", 11 | "enumerate": "\\begin{enumerate}\n\t\\item $1\n\\end{enumerate}", 12 | "figure": "\\begin{figure}\n\t\\centering\n\t\\includegraphics{$1}\n\t\\caption{${2:Caption}}\n\t\\label{${3:fig:enter-label}}\n\\end{figure}", 13 | "frame": "\\begin{frame}{${1:Frame Title}}\n\t$2\n\\end{frame}", 14 | "itemize": "\\begin{itemize}\n\t\\item $1\n\\end{itemize}", 15 | "minipage": "\\begin{minipage}{${1:width}}\n\t$2\n\\end{minipage}", 16 | "pmatrix": "\\begin{pmatrix}\n\t$1 & $2 \\\\\n\t$3 & $4\n\\end{pmatrix}", 17 | "tabbing": "\\begin{tabbing}\n\t\\hspace{\\${1:width}} \\= \\hspace{\\${2:width}} \\= \\kill\n\t$3\n\\end{tabbing}", 18 | "table": "\\begin{table}[$1]\n\t\\centering\n\t\\begin{tabular}{${2:c|c}}\n\t\t$3 & $4 \\\\\n\t\t$5 & $6\n\t\\end{tabular}\n\t\\caption{${7:Caption}}\n\t\\label{${8:tab:my_label}}\n\\end{table}", 19 | "tabular": "\\begin{tabular}{${1:c|c}}\n\t$2 & $3 \\\\\n\t$4 & $5\n\\end{tabular}" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /data/latex/package-names.json: -------------------------------------------------------------------------------- 1 | [ 2 | "inputenc", 3 | "graphicx", 4 | "amsmath", 5 | "geometry", 6 | "amssymb", 7 | "hyperref", 8 | "babel", 9 | "color", 10 | "xcolor", 11 | "url", 12 | "natbib", 13 | "fontenc", 14 | "fancyhdr", 15 | "amsfonts", 16 | "booktabs", 17 | "amsthm", 18 | "float", 19 | "tikz", 20 | "caption", 21 | "setspace", 22 | "multirow", 23 | "array", 24 | "multicol", 25 | "titlesec", 26 | "enumitem", 27 | "ifthen", 28 | "listings", 29 | "blindtext", 30 | "subcaption", 31 | "times", 32 | "bm", 33 | "subfigure", 34 | "algorithm", 35 | "fontspec", 36 | "biblatex", 37 | "tabularx", 38 | "microtype", 39 | "etoolbox", 40 | "parskip", 41 | "calc", 42 | "verbatim", 43 | "mathtools", 44 | "epsfig", 45 | "wrapfig", 46 | "lipsum", 47 | "cite", 48 | "textcomp", 49 | "longtable", 50 | "textpos", 51 | "algpseudocode", 52 | "enumerate", 53 | "subfig", 54 | "pdfpages", 55 | "epstopdf", 56 | "latexsym", 57 | "lmodern", 58 | "pifont", 59 | "ragged2e", 60 | "rotating", 61 | "dcolumn", 62 | "xltxtra", 63 | "marvosym", 64 | "indentfirst", 65 | "xspace", 66 | "csquotes", 67 | "xparse", 68 | "changepage", 69 | "soul", 70 | "xunicode", 71 | "comment", 72 | "mathrsfs", 73 | "tocbibind", 74 | "lastpage", 75 | "algorithm2e", 76 | "pgfplots", 77 | "lineno", 78 | "algorithmic", 79 | "fullpage", 80 | "mathptmx", 81 | "todonotes", 82 | "ulem", 83 | "tweaklist", 84 | "moderncvstyleclassic", 85 | "collection", 86 | "moderncvcompatibility", 87 | "gensymb", 88 | "helvet", 89 | "siunitx", 90 | "adjustbox", 91 | "placeins", 92 | "colortbl", 93 | "appendix", 94 | "makeidx", 95 | "supertabular", 96 | "ifpdf", 97 | "framed", 98 | "aliascnt", 99 | "layaureo", 100 | "authblk" 101 | ] 102 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Index of Documentation 2 | 3 | ## For Users 4 | 5 | - [Servers Management](wiki.md#servers-management) 6 | - [Projects Management](wiki.md#projects-management) 7 | - [Basic Usage](wiki.md#basic-usage) 8 | - [Files Management](wiki.md#files-management) 9 | - [Compile the Project](wiki.md#compile-the-project) 10 | - [Preview Document](wiki.md#preview-document) 11 | - [Intellisense](wiki.md#intellisense) 12 | - [Collaboration](wiki.md#collaboration) 13 | - [Advanced Usage](wiki.md#advanced-usage) 14 | - [Advance Chat Message](wiki.md#advance-chat-message) 15 | - [Local Replica (Source Control)](wiki.md#local-replica-source-control) 16 | - [Invisible Mode](wiki.md#invisible-mode) 17 | - [Commands and Shortcuts](wiki.md#commands-and-shortcuts) 18 | - [Configurations](wiki.md#configurations) 19 | - [Frequently Asked Questions (FAQ)](wiki.md#faq) 20 | 21 | ## For Developers 22 | - [Overleaf Server Public API](webapi.md) 23 | - [Overleaf Workshop Extension Anatomy](anatomy.md) 24 | - [How to Contribute](../CONTRIBUTING.md#contribution-guidance) 25 | - [How to Build](../CONTRIBUTING.md#development-guidance) 26 | -------------------------------------------------------------------------------- /docs/assets/demo01-login.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/demo01-login.gif -------------------------------------------------------------------------------- /docs/assets/demo02-compile.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/demo02-compile.gif -------------------------------------------------------------------------------- /docs/assets/demo03-synctex.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/demo03-synctex.gif -------------------------------------------------------------------------------- /docs/assets/demo04-dark-mode.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/demo04-dark-mode.gif -------------------------------------------------------------------------------- /docs/assets/demo05-intellisense.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/demo05-intellisense.png -------------------------------------------------------------------------------- /docs/assets/demo06-chat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/demo06-chat.gif -------------------------------------------------------------------------------- /docs/assets/demo07-local.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/demo07-local.gif -------------------------------------------------------------------------------- /docs/assets/login_with_cookie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/login_with_cookie.png -------------------------------------------------------------------------------- /docs/assets/screenshot-add-server-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-add-server-button.png -------------------------------------------------------------------------------- /docs/assets/screenshot-add-server-inputbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-add-server-inputbox.png -------------------------------------------------------------------------------- /docs/assets/screenshot-advance-chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-advance-chat.png -------------------------------------------------------------------------------- /docs/assets/screenshot-change-compiler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-change-compiler.png -------------------------------------------------------------------------------- /docs/assets/screenshot-chat-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-chat-view.png -------------------------------------------------------------------------------- /docs/assets/screenshot-commands-and-shortcuts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-commands-and-shortcuts.png -------------------------------------------------------------------------------- /docs/assets/screenshot-compile-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-compile-output.png -------------------------------------------------------------------------------- /docs/assets/screenshot-compile-status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-compile-status.png -------------------------------------------------------------------------------- /docs/assets/screenshot-config-local-replica.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-config-local-replica.png -------------------------------------------------------------------------------- /docs/assets/screenshot-create-local-replica.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-create-local-replica.png -------------------------------------------------------------------------------- /docs/assets/screenshot-create-new-project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-create-new-project.png -------------------------------------------------------------------------------- /docs/assets/screenshot-document-outline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-document-outline.png -------------------------------------------------------------------------------- /docs/assets/screenshot-enter-invisible-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-enter-invisible-mode.png -------------------------------------------------------------------------------- /docs/assets/screenshot-formatter-linebreak.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-formatter-linebreak.png -------------------------------------------------------------------------------- /docs/assets/screenshot-invisible-mode-status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-invisible-mode-status.png -------------------------------------------------------------------------------- /docs/assets/screenshot-line-reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-line-reference.png -------------------------------------------------------------------------------- /docs/assets/screenshot-local-replica-status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-local-replica-status.png -------------------------------------------------------------------------------- /docs/assets/screenshot-login-to-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-login-to-server.png -------------------------------------------------------------------------------- /docs/assets/screenshot-logout-from-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-logout-from-server.png -------------------------------------------------------------------------------- /docs/assets/screenshot-online-collaborators.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-online-collaborators.png -------------------------------------------------------------------------------- /docs/assets/screenshot-open-project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-open-project.png -------------------------------------------------------------------------------- /docs/assets/screenshot-pro-import-external-file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-pro-import-external-file.png -------------------------------------------------------------------------------- /docs/assets/screenshot-pro-refresh-external-file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-pro-refresh-external-file.png -------------------------------------------------------------------------------- /docs/assets/screenshot-project-history-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-project-history-diff.png -------------------------------------------------------------------------------- /docs/assets/screenshot-project-history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-project-history.png -------------------------------------------------------------------------------- /docs/assets/screenshot-project-tag-management.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-project-tag-management.png -------------------------------------------------------------------------------- /docs/assets/screenshot-refresh-project-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-refresh-project-list.png -------------------------------------------------------------------------------- /docs/assets/screenshot-setup-local-replica-folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-setup-local-replica-folder.png -------------------------------------------------------------------------------- /docs/assets/screenshot-spell-check-suggestions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-spell-check-suggestions.png -------------------------------------------------------------------------------- /docs/assets/screenshot-spell-check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-spell-check.png -------------------------------------------------------------------------------- /docs/assets/screenshot-vscode-configurations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/docs/assets/screenshot-vscode-configurations.png -------------------------------------------------------------------------------- /l10n/bundle.l10n.json: -------------------------------------------------------------------------------- 1 | { 2 | "Cookie Expired. Please Re-Login": "Cookie Expired. Please Re-Login", 3 | "Project Source Control": "Project Source Control", 4 | "Click to configure.": "Click to configure.", 5 | "Disabled": "Disabled", 6 | "Synced": "Synced", 7 | "\"{scm}\" creation failed.": "\"{scm}\" creation failed.", 8 | "Create Source Control: {scm}": "Create Source Control: {scm}", 9 | "\"{scm}\" created: {uri}.": "\"{scm}\" created: {uri}.", 10 | "Project Source Control Management": "Project Source Control Management", 11 | "Remove": "Remove", 12 | "Local Replica": "Local Replica", 13 | "Invalid Path. Please make sure the absolute path to a folder with read/write permissions is used.": "Invalid Path. Please make sure the absolute path to a folder with read/write permissions is used.", 14 | "Sync Files": "Sync Files", 15 | "e.g., /home/user/empty/local/folder": "e.g., /home/user/empty/local/folder", 16 | "Configure sync ignore patterns ...": "Configure sync ignore patterns ...", 17 | "Press Enter to add a new pattern, or click the trash icon to remove a pattern.": "Press Enter to add a new pattern, or click the trash icon to remove a pattern.", 18 | "Compare with Previous Version": "Compare with Previous Version", 19 | "Load More ...": "Load More ...", 20 | "Create a new label": "Create a new label", 21 | "Enter a label name": "Enter a label name", 22 | "Select a label to delete": "Select a label to delete", 23 | "Select a version to compare": "Select a version to compare", 24 | "Project v{version} saved to {path}": "Project v{version} saved to {path}", 25 | "{word}: Unknown word.": "{word}: Unknown word.", 26 | "Spell Checker": "Spell Checker", 27 | "Add to Dictionary": "Add to Dictionary", 28 | "Select a word to unlearn": "Select a word to unlearn", 29 | "Manage Dictionary": "Manage Dictionary", 30 | "Select spell check language": "Select spell check language", 31 | "Spell Check": "Spell Check", 32 | "Click to manage spell check.": "Click to manage spell check.", 33 | "Cannot init SocketIOAPI for {serverName}": "Cannot init SocketIOAPI for {serverName}", 34 | "Connection lost: {serverName}": "Connection lost: {serverName}", 35 | "Reload": "Reload", 36 | "Connection lost": "Connection lost", 37 | "Refreshing": "Refreshing", 38 | "Done": "Done", 39 | "From Another Project": "From Another Project", 40 | "From External URL": "From External URL", 41 | "Import file from...": "Import file from...", 42 | "Select a Project": "Select a Project", 43 | "Select a File": "Select a File", 44 | "File Name In This Project": "File Name In This Project", 45 | "File name is empty or contains invalid characters": "File name is empty or contains invalid characters", 46 | "A file or folder with this name already exists": "A file or folder with this name already exists", 47 | "URL to fetch the file from": "URL to fetch the file from", 48 | "Cannot rename across servers": "Cannot rename across servers", 49 | "Overleaf server address, e.g. \"https://www.overleaf.com\"": "Overleaf server address, e.g. \"https://www.overleaf.com\"", 50 | "Invalid protocol.": "Invalid protocol.", 51 | "Invalid server address.": "Invalid server address.", 52 | "Remove server \"{name}\" ?": "Remove server \"{name}\" ?", 53 | "Email": "Email", 54 | "Password": "Password", 55 | "Login failed.": "Login failed.", 56 | "Cookies, e.g., \"sharelatex.sid=...\" or \"overleaf_session2=...\"": "Cookies, e.g., \"sharelatex.sid=...\" or \"overleaf_session2=...\"", 57 | "README: [How to Login with Cookies](https://github.com/iamhyc/overleaf-workshop#how-to-login-with-cookies)": "README: [How to Login with Cookies](https://github.com/iamhyc/overleaf-workshop#how-to-login-with-cookies)", 58 | "Select the login method below.": "Select the login method below.", 59 | "Logout server \"{name}\" ?": "Logout server \"{name}\" ?", 60 | "Blank Project": "Blank Project", 61 | "Example Project": "Example Project", 62 | "Upload Project": "Upload Project", 63 | "Project name": "Project name", 64 | "Upload Zipped Project": "Upload Zipped Project", 65 | "New Project Name": "New Project Name", 66 | "Copy Project": "Copy Project", 67 | "Permanently delete project \"{label}\" ?": "Permanently delete project \"{label}\" ?", 68 | "Archive project \"{label}\" ?": "Archive project \"{label}\" ?", 69 | "Move project \"{label}\" to trash ?": "Move project \"{label}\" to trash ?", 70 | "Tag name": "Tag name", 71 | "New tag name": "New tag name", 72 | "Delete tag \"{label}\" ?": "Delete tag \"{label}\" ?", 73 | "Select the tag below.": "Select the tag below.", 74 | "Remove project \"{label}\" from tag \"{name}\" ?": "Remove project \"{label}\" from tag \"{name}\" ?", 75 | "Please close the open remote overleaf folder firstly.": "Please close the open remote overleaf folder firstly.", 76 | "No local replica found, create one for project \"{label}\" ?": "No local replica found, create one for project \"{label}\" ?", 77 | "Remove local replica": "Remove local replica", 78 | "Select the local replica below.": "Select the local replica below.", 79 | "Remove local replica \"{label}\" ?": "Remove local replica \"{label}\" ?", 80 | "Compile Checker": "Compile Checker", 81 | "Compile Success": "Compile Success", 82 | "Compiling": "Compiling", 83 | "Compile Failed": "Compile Failed", 84 | "Not Connected": "Not Connected", 85 | "Click to manage compile settings.": "Click to manage compile settings.", 86 | "Select Compiler": "Select Compiler", 87 | "Select Main Document": "Select Main Document", 88 | "Draft Mode": "Draft Mode", 89 | "Normal Mode": "Normal Mode", 90 | "Stop on first error": "Stop on first error", 91 | "Try to compile despite errors": "Try to compile despite errors", 92 | "Compile Mode": "Compile Mode", 93 | "Compile Error Handling": "Compile Error Handling", 94 | "Setting: Compiler": "Setting: Compiler", 95 | "Setting: Main Document": "Setting: Main Document", 96 | "Stop compilation": "Stop compilation", 97 | "No online Collaborators.": "No online Collaborators.", 98 | "At {docPath}, Line {row}": "At {docPath}, Line {row}", 99 | "Select a collaborator below to jump to.": "Select a collaborator below to jump to.", 100 | "Not connected": "Not connected", 101 | "Online": "Online", 102 | "Active": "Active", 103 | "Idle": "Idle", 104 | "Just now": "Just now", 105 | "{since_last_update} ago": "{since_last_update} ago", 106 | "Jump to Collaborator ...": "Jump to Collaborator ...", 107 | "Upload Unsaved {number} Change(s)": "Upload Unsaved {number} Change(s)", 108 | "Invisible Mode removes your presence from others' view.": "Invisible Mode removes your presence from others' view.", 109 | "Back to normal mode.": "Back to normal mode.", 110 | "(Experimental Feature) By entering Invisible Mode, the current connection to the server will be lost. Continue?": "(Experimental Feature) By entering Invisible Mode, the current connection to the server will be lost. Continue?", 111 | "Uploading Changes": "Uploading Changes", 112 | "Failed to upload": "Failed to upload" 113 | } -------------------------------------------------------------------------------- /package.nls.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension.displayName": "Overleaf Workshop", 3 | "extension.description": "Open Overleaf/ShareLaTex projects in VSCode, with full collaboration support.", 4 | 5 | "commands.remoteFileSystem.refreshLinkedFile.title": "Overleaf: Refresh File", 6 | "commands.remoteFileSystem.createLinkedFile.title": "Overleaf: Import File ...", 7 | 8 | "commands.projectManager.addServer.title": "Add New Server", 9 | "commands.projectManager.removeServer.title": "Remove Server", 10 | "commands.projectManager.loginServer.title": "Login to Server", 11 | "commands.projectManager.logoutServer.title": "Logout from Server", 12 | "commands.projectManager.refreshServer.title": "Refresh Project List", 13 | "commands.projectManager.newProject.title": "New Project", 14 | "commands.projectManager.copyProject.title": "Make a Copy", 15 | "commands.projectManager.renameProject.title": "Rename Project", 16 | "commands.projectManager.deleteProject.title": "Delete Project", 17 | "commands.projectManager.archiveProject.title": "Archive Project", 18 | "commands.projectManager.unarchiveProject.title": "Unarchive Project", 19 | "commands.projectManager.trashProject.title": "Move Project to Trash", 20 | "commands.projectManager.untrashProject.title": "Restore Project", 21 | "commands.projectManager.createTag.title": "Create Tag", 22 | "commands.projectManager.renameTag.title": "Rename Tag", 23 | "commands.projectManager.deleteTag.title": "Delete Tag", 24 | "commands.projectManager.addProjectToTag.title": "Add Project to Tag", 25 | "commands.projectManager.removeProjectFromTag.title": "Remove Project from Tag", 26 | "commands.projectManager.openProjectInCurrentWindow.title": "Open Project in Current Window", 27 | "commands.projectManager.openProjectInNewWindow.title": "Open Project in New Window", 28 | "commands.projectManager.openProjectLocalReplica.title": "Open Project Locally ...", 29 | 30 | "commands.projectHistory.refresh.title": "Refresh Project History", 31 | "commands.projectHistory.clearSelection.title": "Clear Selection", 32 | "commands.projectHistory.createLabel.title": "Add New Label", 33 | "commands.projectHistory.deleteLabel.title": "Delete Label", 34 | "commands.projectHistory.comparePrevious.title": "Compare with Previous Version", 35 | "commands.projectHistory.compareCurrent.title": "Compare with Current Version", 36 | "commands.projectHistory.compareOthers.title": "Compare with Other Version ...", 37 | "commands.projectHistory.downloadProject.title": "Download Project", 38 | "commands.projectHistory.revealHistoryView.title": "Focus on Overleaf History View", 39 | "commands.projectSCM.configSCM.title": "Configure Source Control ...", 40 | 41 | "commands.compileManager.compile.title": "Compile Project", 42 | "commands.compileManager.viewPdf.title": "View Compiled PDF", 43 | "commands.compileManager.syncCode.title": "Jump to PDF", 44 | "commands.compileManager.setCompiler.title": "Set Compiler", 45 | "commands.compileManager.setRootDoc.title": "Set Root Document", 46 | 47 | "commands.collaboration.copyLineRef.title": "Overleaf: Copy Line Reference", 48 | "commands.collaboration.insertLineRef.title": "Overleaf: Insert Line Reference", 49 | "commands.collaboration.revealChatView.title": "Focus on Overleaf Chat View", 50 | "commands.collaboration.jumpToUser.title": "Jump to Collaborator ...", 51 | 52 | "configuration.compileOnSave.enabled.markdownDescription": "Always update the compiled PDF when a file is saved.", 53 | "configuration.compileOutputFolderName.markdownDescription": "The name of the folder where the compiled output files (e.g., `output.pdf`) is located. (Take effect after restarting VSCode)", 54 | "configuration.pdfViewer.themes.markdownDescription": "Configure the color themes used by the PDF viewer. (Take effect after restarting VSCode)", 55 | "configuration.pdfViewer.themes.theme.description": "The name of the theme.", 56 | "configuration.pdfViewer.themes.fontColor.description": "The font color of the theme.", 57 | "configuration.pdfViewer.themes.backgroundColor.description": "The background color of the theme.", 58 | "configuration.invisibleMode.historyRefreshInterval.markdownDescription": "The interval (in seconds) to refresh the project history in invisible mode, which is used to update the file changes, collaborator status, etc.", 59 | "configuration.invisibleMode.chatMessageRefreshInterval.markdownDescription": "The interval (in seconds) to refresh the chat messages in invisible mode.", 60 | "configuration.invisibleMode.inactiveTimeout.markdownDescription": "The timeout (in seconds) to mark a online Collaborator as inactivate in invisible mode.", 61 | "configuration.formatWithLineBreak.enabled.markdownDescription": "Enable line breaks when formatting.", 62 | 63 | "views.overleaf-workshop.projectManager.name": "Hosts", 64 | "views.explorer.overleaf-workshop.projectHistory": "History", 65 | "views.explorer.overleaf-workshop.contextualTitle": "Overleaf Project History", 66 | "views.explorer.overleaf-workshop.chatWebview.name": "Chat", 67 | "views.explorer.overleaf-workshop.chatWebview.contextualTitle": "Overleaf Chat", 68 | 69 | "customEditors.overleaf-workshop.pdfViewer.displayName": "Overleaf Workshop PDF Viewer" 70 | } -------------------------------------------------------------------------------- /patches/socket.io-client+0.9.17-overleaf-5.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/socket.io-client/lib/socket.js b/node_modules/socket.io-client/lib/socket.js 2 | index 07c1c3f..e208abc 100644 3 | --- a/node_modules/socket.io-client/lib/socket.js 4 | +++ b/node_modules/socket.io-client/lib/socket.js 5 | @@ -152,11 +152,27 @@ 6 | if (this.isXDomain()) { 7 | xhr.withCredentials = true; 8 | } 9 | + if (this.options['extraHeaders']) { 10 | + xhr.setDisableHeaderCheck(true); 11 | + Object.entries(this.options['extraHeaders']).forEach(([key, value]) => { 12 | + xhr.setRequestHeader(key, value); 13 | + }); 14 | + } 15 | xhr.onreadystatechange = function () { 16 | if (xhr.readyState == 4) { 17 | xhr.onreadystatechange = empty; 18 | 19 | if (xhr.status == 200) { 20 | + // extract set-cookie headers 21 | + const matches = xhr.getAllResponseHeaders().match(/set-cookie:\s*([^\r\n]+)/gi); 22 | + matches && matches.forEach(function (header) { 23 | + const newCookie = header.split(':')[1].split(';')[0].trim(); 24 | + const optCookie = self.options['extraHeaders']['Cookie']; 25 | + const mergedCookie = optCookie ? `${optCookie}; ${newCookie}` : newCookie; 26 | + self.options['extraHeaders'] = self.options['extraHeaders'] || {}; 27 | + self.options['extraHeaders']['Cookie'] = mergedCookie; 28 | + }); 29 | + 30 | complete(xhr.responseText); 31 | } else if (xhr.status == 403) { 32 | self.onError(xhr.responseText); 33 | @@ -229,7 +245,7 @@ 34 | self.transport.ready(self, function () { 35 | self.connecting = true; 36 | self.publish('connecting', self.transport.name); 37 | - self.transport.open(); 38 | + self.transport.open(self.options['extraHeaders']); 39 | 40 | if (self.options['connect timeout']) { 41 | self.connectTimeoutTimer = setTimeout(function () { 42 | @@ -369,6 +385,12 @@ 43 | ].join('/') + '/?disconnect=1'; 44 | 45 | xhr.open('GET', uri, false); 46 | + if (this.options['extraHeaders']) { 47 | + xhr.setDisableHeaderCheck(true); 48 | + Object.entries(this.options['extraHeaders']).forEach(([key, value]) => { 49 | + xhr.setRequestHeader(key, value); 50 | + }); 51 | + } 52 | xhr.send(null); 53 | 54 | // handle disconnection immediately 55 | diff --git a/node_modules/socket.io-client/lib/transports/websocket.js b/node_modules/socket.io-client/lib/transports/websocket.js 56 | index 0dfa483..d878bbb 100644 57 | --- a/node_modules/socket.io-client/lib/transports/websocket.js 58 | +++ b/node_modules/socket.io-client/lib/transports/websocket.js 59 | @@ -50,7 +50,7 @@ 60 | * @api public 61 | */ 62 | 63 | - WS.prototype.open = function () { 64 | + WS.prototype.open = function (extraHeaders) { 65 | var query = io.util.query(this.socket.options.query) 66 | , self = this 67 | , Socket 68 | @@ -63,7 +63,9 @@ 69 | Socket = global.MozWebSocket || global.WebSocket; 70 | } 71 | 72 | - this.websocket = new Socket(this.prepareUrl() + query); 73 | + this.websocket = new Socket(this.prepareUrl() + query, { 74 | + headers: extraHeaders || {} 75 | + }); 76 | 77 | this.websocket.onopen = function () { 78 | self.onOpen(); 79 | -------------------------------------------------------------------------------- /resources/icons/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamhyc/Overleaf-Workshop/f3fc02260ae06f7af2cf517574d98fe721027289/resources/icons/app_icon.png -------------------------------------------------------------------------------- /resources/icons/overleaf_bw.svg: -------------------------------------------------------------------------------- 1 | stickers alt -------------------------------------------------------------------------------- /src/api/extendedBase.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { BaseAPI, Identity } from "./base"; 3 | 4 | export interface ProjectLinkedFileProvider { 5 | provider: 'project_file', 6 | source_project_id: string, 7 | source_entity_path: string, 8 | } 9 | 10 | export interface UrlLinkedFileProvider { 11 | provider: 'url', 12 | url: string, 13 | } 14 | 15 | export class ExtendedBaseAPI extends BaseAPI { 16 | async refreshLinkedFile(identity:Identity, project_id:string, file_id:string) { 17 | this.setIdentity(identity); 18 | return await this.request('POST', `project/${project_id}/linked_file/${file_id}/refresh`, {shouldReindexReferences: false}, (res) => { 19 | const message = JSON.parse(res!).new_file_id; 20 | return {message}; 21 | }, {'X-Csrf-Token': identity.csrfToken}); 22 | } 23 | 24 | async createLinkedFile(identity:Identity, project_id:string, parent_folder_id:string, name:string, provider:string, data:any) { 25 | this.setIdentity(identity); 26 | return await this.request('POST', `project/${project_id}/linked_file`, {name, parent_folder_id, provider, data}, (res) => { 27 | const message = JSON.parse(res!).new_file_id; 28 | return {message}; 29 | }, {'X-Csrf-Token': identity.csrfToken}); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/api/socketio.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { Identity, BaseAPI, ProjectMessageResponseSchema } from './base'; 3 | import { FileEntity, DocumentEntity, FileRefEntity, FileType, FolderEntity, ProjectEntity } from '../core/remoteFileSystemProvider'; 4 | import { EventBus } from '../utils/eventBus'; 5 | import { SocketIOAlt } from './socketioAlt'; 6 | 7 | export interface UpdateUserSchema { 8 | id: string, 9 | user_id: string, 10 | name: string, 11 | email: string, 12 | doc_id: string, 13 | row: number, 14 | column: number, 15 | last_updated_at?: number, //unix timestamp 16 | } 17 | 18 | export interface OnlineUserSchema { 19 | client_age: number, 20 | client_id: string, 21 | connected: boolean, 22 | cursorData?: { 23 | column: number, 24 | doc_id: string, 25 | row: number, 26 | }, 27 | email: string, 28 | first_name: string, 29 | last_name?: string, 30 | last_updated_at: string, //unix timestamp 31 | user_id: string, 32 | } 33 | 34 | export interface UpdateSchema { 35 | doc: string, //doc id 36 | op?: { 37 | p: number, //position 38 | i?: string, //insert 39 | d?: string, //delete 40 | u?: boolean, //isUndo 41 | }[], 42 | v: number, //doc version number 43 | lastV?: number, //last version number 44 | hash?: string, //(not needed if lastV is provided) 45 | meta?: { 46 | source: string, //socketio client id 47 | ts: number, //unix timestamp 48 | user_id: string, 49 | } 50 | } 51 | 52 | export interface EventsHandler { 53 | onFileCreated?: (parentFolderId:string, type:FileType, entity:FileEntity) => void, 54 | onFileRenamed?: (entityId:string, newName:string) => void, 55 | onFileRemoved?: (entityId:string) => void, 56 | onFileMoved?: (entityId:string, newParentFolderId:string) => void, 57 | onFileChanged?: (update:UpdateSchema) => void, 58 | // 59 | onDisconnected?: () => void, 60 | onConnectionAccepted?: (publicId:string) => void, 61 | onClientUpdated?: (user:UpdateUserSchema) => void, 62 | onClientDisconnected?: (id:string) => void, 63 | // 64 | onReceivedMessage?: (message:ProjectMessageResponseSchema) => void, 65 | // 66 | onSpellCheckLanguageUpdated?: (language:string) => void, 67 | onCompilerUpdated?: (compiler:string) => void, 68 | onRootDocUpdated?: (rootDocId:string) => void, 69 | } 70 | 71 | type ConnectionScheme = 'Alt' | 'v1' | 'v2'; 72 | 73 | export class SocketIOAPI { 74 | private scheme: ConnectionScheme = 'v1'; 75 | private record?: Promise; 76 | private _handlers: Array = []; 77 | 78 | private socket?: any; 79 | private emit: any; 80 | 81 | constructor(private url:string, 82 | private readonly api:BaseAPI, 83 | private readonly identity:Identity, 84 | private readonly projectId:string) 85 | { 86 | this.init(); 87 | } 88 | 89 | init() { 90 | // connect 91 | switch(this.scheme) { 92 | case 'Alt': 93 | this.socket = new SocketIOAlt(this.url, this.api, this.identity, this.projectId, this.record!); 94 | break; 95 | case 'v1': 96 | this.record = undefined; 97 | this.socket = this.api._initSocketV0(this.identity); 98 | break; 99 | case 'v2': 100 | this.record = undefined; 101 | const query = `?projectId=${this.projectId}&t=${Date.now()}`; 102 | this.socket = this.api._initSocketV0(this.identity, query); 103 | break; 104 | } 105 | // create emit 106 | (this.socket.emit)[require('util').promisify.custom] = (event:string, ...args:any[]) => { 107 | const timeoutPromise = new Promise((_, reject) => { 108 | setTimeout(() => { 109 | reject('timeout'); 110 | }, 5000); 111 | }); 112 | const waitPromise = new Promise((resolve, reject) => { 113 | this.socket.emit(event, ...args, (err:any, ...data:any[]) => { 114 | if (err) { 115 | reject(err); 116 | } else { 117 | resolve(data); 118 | } 119 | }); 120 | }); 121 | return Promise.race([waitPromise, timeoutPromise]); 122 | }; 123 | this.emit = require('util').promisify(this.socket.emit).bind(this.socket); 124 | // resume handlers 125 | this.initInternalHandlers(); 126 | // this.resumeEventHandlers(this._handlers); 127 | } 128 | 129 | private initInternalHandlers() { 130 | this.socket.on('connect', () => { 131 | console.log('SocketIOAPI: connected'); 132 | }); 133 | this.socket.on('connect_failed', () => { 134 | console.log('SocketIOAPI: connect_failed'); 135 | }); 136 | this.socket.on('forceDisconnect', (message:string, delay=10) => { 137 | console.log('SocketIOAPI: forceDisconnect', message); 138 | }); 139 | this.socket.on('connectionRejected', (err:any) => { 140 | console.log('SocketIOAPI: connectionRejected.', err.message); 141 | }); 142 | this.socket.on('error', (err:any) => { 143 | throw new Error(err); 144 | }); 145 | 146 | if (this.scheme==='v2') { 147 | this.record = new Promise(resolve => { 148 | this.socket.on('joinProjectResponse', (res:any) => { 149 | const publicId = res.publicId as string; 150 | const project = res.project as ProjectEntity; 151 | EventBus.fire('socketioConnectedEvent', {publicId}); 152 | resolve(project); 153 | }); 154 | }); 155 | } 156 | } 157 | 158 | disconnect() { 159 | this.socket.disconnect(); 160 | } 161 | 162 | get handlers() { 163 | return this._handlers; 164 | } 165 | 166 | get isUsingAlternativeConnectionScheme() { 167 | return this.scheme==='Alt'; 168 | } 169 | 170 | toggleAlternativeConnectionScheme(url: string, updatedRecord?: ProjectEntity) { 171 | this.scheme = this.scheme==='Alt' ? 'v1' : 'Alt'; 172 | if (updatedRecord) { 173 | this.url = url; 174 | this.record = Promise.resolve(updatedRecord); 175 | } 176 | } 177 | 178 | resumeEventHandlers(handlers: Array) { 179 | this._handlers = []; 180 | handlers.forEach((handler) => { 181 | this.updateEventHandlers(handler); 182 | }); 183 | } 184 | 185 | updateEventHandlers(handlers: EventsHandler) { 186 | this._handlers.push(handlers); 187 | Object.values(handlers).forEach((handler) => { 188 | switch (handler) { 189 | case handlers.onFileCreated: 190 | this.socket.on('reciveNewDoc', (parentFolderId:string, doc:DocumentEntity) => { 191 | handler(parentFolderId, 'doc', doc); 192 | }); 193 | this.socket.on('reciveNewFile', (parentFolderId:string, file:FileRefEntity) => { 194 | handler(parentFolderId, 'file', file); 195 | }); 196 | this.socket.on('reciveNewFolder', (parentFolderId:string, folder:FolderEntity) => { 197 | handler(parentFolderId, 'folder', folder); 198 | }); 199 | break; 200 | case handlers.onFileRenamed: 201 | this.socket.on('reciveEntityRename', (entityId:string, newName:string) => { 202 | handler(entityId, newName); 203 | }); 204 | break; 205 | case handlers.onFileRemoved: 206 | this.socket.on('removeEntity', (entityId:string) => { 207 | handler(entityId); 208 | }); 209 | break; 210 | case handlers.onFileMoved: 211 | this.socket.on('reciveEntityMove', (entityId:string, folderId:string) => { 212 | handler(entityId, folderId); 213 | }); 214 | break; 215 | case handlers.onFileChanged: 216 | this.socket.on('otUpdateApplied', (update: UpdateSchema) => { 217 | handler(update); 218 | }); 219 | break; 220 | case handlers.onDisconnected: 221 | this.socket.on('disconnect', () => { 222 | handler(); 223 | }); 224 | break; 225 | case handlers.onConnectionAccepted: 226 | this.socket.on('connectionAccepted', (_:any, publicId:any) => { 227 | handler(publicId); 228 | }); 229 | EventBus.on('socketioConnectedEvent', (arg:{publicId:string}) => { 230 | handler(arg.publicId); 231 | }); 232 | break; 233 | case handlers.onClientUpdated: 234 | this.socket.on('clientTracking.clientUpdated', (user:UpdateUserSchema) => { 235 | handler(user); 236 | }); 237 | break; 238 | case handlers.onClientDisconnected: 239 | this.socket.on('clientTracking.clientDisconnected', (id:string) => { 240 | handler(id); 241 | }); 242 | break; 243 | case handlers.onReceivedMessage: 244 | this.socket.on('new-chat-message', (message:ProjectMessageResponseSchema) => { 245 | handler(message); 246 | }); 247 | break; 248 | case handlers.onSpellCheckLanguageUpdated: 249 | this.socket.on('spellCheckLanguageUpdated', (language:string) => { 250 | handler(language); 251 | }); 252 | break; 253 | case handlers.onCompilerUpdated: 254 | this.socket.on('compilerUpdated', (compiler:string) => { 255 | handler(compiler); 256 | }); 257 | break; 258 | case handlers.onRootDocUpdated: 259 | this.socket.on('rootDocUpdated', (rootDocId:string) => { 260 | handler(rootDocId); 261 | }); 262 | break; 263 | default: 264 | break; 265 | } 266 | }); 267 | } 268 | 269 | get unSyncFileChanges(): number { 270 | if (this.socket instanceof SocketIOAlt) { 271 | return this.socket.unSyncedChanges; 272 | } 273 | return 0; 274 | } 275 | 276 | async syncFileChanges() { 277 | if (this.socket instanceof SocketIOAlt) { 278 | return await this.socket.uploadToVFS(); 279 | } 280 | } 281 | 282 | /** 283 | * Reference: services/web/frontend/js/ide/connection/ConnectionManager.js#L427 284 | * @param {string} projectId - The project id. 285 | * @returns {Promise} 286 | */ 287 | async joinProject(project_id:string): Promise { 288 | const timeoutPromise: Promise = new Promise((_, reject) => { 289 | setTimeout(() => { 290 | reject('timeout'); 291 | }, 5000); 292 | }); 293 | 294 | switch(this.scheme) { 295 | case 'Alt': 296 | case 'v1': 297 | const joinPromise = this.emit('joinProject', {project_id}) 298 | .then((returns:[ProjectEntity, string, number]) => { 299 | const [project, permissionsLevel, protocolVersion] = returns; 300 | this.record = Promise.resolve(project); 301 | return project; 302 | }); 303 | const rejectPromise = new Promise((_, reject) => { 304 | this.socket.on('connectionRejected', (err:any) => { 305 | this.scheme = 'v2'; 306 | reject(err.message); 307 | }); 308 | }); 309 | return Promise.race([joinPromise, rejectPromise, timeoutPromise]); 310 | case 'v2': 311 | return Promise.race([this.record!, timeoutPromise]); 312 | } 313 | } 314 | 315 | /** 316 | * Reference: services/web/frontend/js/ide/editor/Document.js#L500 317 | * @param {string} docId - The document id. 318 | * @returns {Promise} 319 | */ 320 | async joinDoc(docId:string) { 321 | return this.emit('joinDoc', docId, { encodeRanges: true }) 322 | .then((returns: [Array, number, Array, any]) => { 323 | const [docLinesAscii, version, updates, ranges] = returns; 324 | const docLines = docLinesAscii.map((line) => Buffer.from(line, 'ascii').toString('utf-8') ); 325 | return {docLines, version, updates, ranges}; 326 | }); 327 | } 328 | 329 | /** 330 | * Reference: services/web/frontend/js/ide/editor/Document.js#L591 331 | * @param {string} docId - The document id. 332 | * @returns {Promise} 333 | */ 334 | async leaveDoc(docId:string) { 335 | return this.emit('leaveDoc', docId) 336 | .then(() => { 337 | return; 338 | }); 339 | } 340 | 341 | /** 342 | * Reference: services/web/frontend/js/ide/editor/ShareJsDocs.js#L78 343 | * @param {string} docId - The document id. 344 | * @param {any} update - The changes. 345 | * @returns {Promise} 346 | */ 347 | async applyOtUpdate(docId:string, update:UpdateSchema) { 348 | return this.emit('applyOtUpdate', docId, update) 349 | .then(() => { 350 | return; 351 | }); 352 | } 353 | 354 | /** 355 | * Reference: services/web/frontend/js/ide/online-users/OnlineUserManager.js#L42 356 | * @returns {Promise} 357 | */ 358 | async getConnectedUsers(): Promise { 359 | return this.emit('clientTracking.getConnectedUsers') 360 | .then((returns:[OnlineUserSchema[]]) => { 361 | const [connectedUsers] = returns; 362 | return connectedUsers; 363 | }); 364 | } 365 | 366 | /** 367 | * Reference: services/web/frontend/js/ide/online-users/OnlineUserManager.js#L150 368 | * @param {string} docId - The document id. 369 | * @returns {Promise} 370 | */ 371 | async updatePosition(doc_id:string, row:number, column:number) { 372 | return this.emit('clientTracking.updatePosition', {row, column, doc_id}) 373 | .then(() => { 374 | return; 375 | }); 376 | } 377 | } 378 | -------------------------------------------------------------------------------- /src/collaboration/chatViewProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { SocketIOAPI } from '../api/socketio'; 3 | import { ProjectMessageResponseSchema } from '../api/base'; 4 | import { VirtualFileSystem, parseUri } from '../core/remoteFileSystemProvider'; 5 | import { ROOT_NAME } from '../consts'; 6 | import { LocalReplicaSCMProvider } from '../scm/localReplicaSCM'; 7 | 8 | export class ChatViewProvider implements vscode.WebviewViewProvider { 9 | private hasUnreadMessages = 0; 10 | private webviewView?: vscode.WebviewView; 11 | 12 | constructor( 13 | private readonly vfs: VirtualFileSystem, 14 | private readonly publicId: string, 15 | private readonly extensionUri: vscode.Uri, 16 | private readonly socket: SocketIOAPI, 17 | ) { 18 | this.socket.updateEventHandlers({ 19 | onReceivedMessage: this.onReceivedMessage.bind(this) 20 | }); 21 | } 22 | 23 | async loadWebviewHtml(webview: vscode.Webview): Promise { 24 | const rootFolder = 'views/chat-view/dist'; 25 | const webviewPath = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, rootFolder)).toString(); 26 | 27 | // load root html 28 | const htmlPath = vscode.Uri.joinPath(this.extensionUri, rootFolder, 'index.html'); 29 | let html = (await vscode.workspace.fs.readFile(htmlPath)).toString(); 30 | 31 | // patch root path (deprecated due to vite-plugin-singlefile) 32 | // html = html.replace(/href="\/(.*?)"/g, `href="${webviewPath}/$1"`); 33 | // html = html.replace(/src="\/(.*?)"/g, `src="${webviewPath}/$1"`); 34 | 35 | return html; 36 | } 37 | 38 | resolveWebviewView(webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, token: vscode.CancellationToken): Thenable { 39 | this.webviewView = webviewView; 40 | return this.loadWebviewHtml(webviewView.webview).then((html) => { 41 | webviewView.webview.options = {enableScripts:true}; 42 | webviewView.webview.html = html; 43 | webviewView.webview.onDidReceiveMessage((e) => { 44 | switch (e.type) { 45 | case 'get-messages': this.getMessages(); break; 46 | case 'send-message': this.sendMessage(e.content); break; 47 | case 'show-line-ref': 48 | const {path, L1, C1, L2, C2} = e.content; 49 | const range = new vscode.Range(L1, C1, L2, C2); 50 | this.showLineRef(path, range); 51 | break; 52 | default: break; 53 | } 54 | }); 55 | }); 56 | } 57 | 58 | private async getMessages() { 59 | const messages = await this.vfs.getMessages(); 60 | if (this.webviewView !== undefined) { 61 | this.webviewView.webview.postMessage({ 62 | type: 'get-messages', 63 | content: messages, 64 | userId: this.vfs._userId, 65 | }); 66 | } 67 | } 68 | 69 | private sendMessage(content: string) { 70 | this.vfs.sendMessage(this.publicId, content); 71 | } 72 | 73 | private onReceivedMessage(message: ProjectMessageResponseSchema) { 74 | if (this.webviewView !== undefined) { 75 | this.webviewView.webview.postMessage({ 76 | type: 'new-message', 77 | content: message, 78 | }); 79 | } 80 | 81 | if (!this.isViewVisible()) { 82 | this.hasUnreadMessages += 1; 83 | } 84 | } 85 | 86 | private isViewVisible() { 87 | return this.webviewView?.visible ?? false; 88 | } 89 | 90 | get hasUnread() { 91 | return this.hasUnreadMessages; 92 | } 93 | 94 | revealChatView() { 95 | // this.webviewView?.show(true); 96 | vscode.commands.executeCommand(`${ROOT_NAME}.chatWebview.focus`); 97 | this.hasUnreadMessages = 0; 98 | } 99 | 100 | insertText(text: string='') { 101 | this.revealChatView(); 102 | if (this.webviewView === undefined) { 103 | setTimeout(() => this.insertText(text), 100); 104 | return; 105 | } 106 | 107 | this.webviewView.webview.postMessage({ 108 | type: 'insert-text', 109 | content: text, 110 | }); 111 | } 112 | 113 | private getLineRef() { 114 | const editor = vscode.window.activeTextEditor; 115 | if (editor === undefined) { return; } 116 | 117 | const filePath = parseUri(editor.document.uri).pathParts.join('/'); 118 | const start = editor.selection.start, end = editor.selection.end; 119 | const ref = `[[${filePath}#L${start.line}C${start.character}-L${end.line}C${end.character}]]`; 120 | return ref; 121 | } 122 | 123 | private async showLineRef(path:string, range:vscode.Range) { 124 | const uri = (vscode.workspace.workspaceFolders?.[0].uri.scheme===ROOT_NAME) ? 125 | this.vfs.pathToUri(path) : await LocalReplicaSCMProvider.pathToUri(path); 126 | if (uri === undefined) { return; } 127 | 128 | vscode.window.showTextDocument(uri).then(editor => { 129 | editor.revealRange(range); 130 | editor.selection = new vscode.Selection(range.start, range.end); 131 | }); 132 | } 133 | 134 | get triggers() { 135 | return [ 136 | // register commands 137 | vscode.commands.registerCommand(`${ROOT_NAME}.collaboration.copyLineRef`, () => { 138 | const ref = this.getLineRef(); 139 | ref && vscode.env.clipboard.writeText(ref); 140 | }), 141 | vscode.commands.registerCommand(`${ROOT_NAME}.collaboration.insertLineRef`, () => { 142 | const ref = this.getLineRef(); 143 | ref && this.insertText(ref + ' '); 144 | }), 145 | // register chat webview 146 | vscode.window.registerWebviewViewProvider(`${ROOT_NAME}.chatWebview`, this, {webviewOptions:{retainContextWhenHidden:true}}), 147 | ]; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export const ROOT_NAME = 'overleaf-workshop'; 4 | export const ELEGANT_NAME = 'Overleaf Workshop'; 5 | 6 | export const OUTPUT_FOLDER_NAME = vscode.workspace.getConfiguration('overleaf-workshop').get('compileOutputFolderName', '.output') || '.output'; 7 | -------------------------------------------------------------------------------- /src/core/pdfViewEditorProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ROOT_NAME } from '../consts'; 3 | import { EventBus } from '../utils/eventBus'; 4 | import { GlobalStateManager } from '../utils/globalStateManager'; 5 | 6 | export class PdfDocument implements vscode.CustomDocument { 7 | cache: Uint8Array = new Uint8Array(0); 8 | 9 | private readonly _onDidChange = new vscode.EventEmitter<{}>(); 10 | readonly onDidChange = this._onDidChange.event; 11 | 12 | constructor(readonly uri: vscode.Uri) { 13 | if (uri.scheme !== ROOT_NAME) { 14 | throw new Error(`Invalid uri scheme: ${uri}`); 15 | } 16 | this.uri = uri; 17 | } 18 | 19 | dispose() { } 20 | 21 | async refresh(): Promise { 22 | try { 23 | this.cache = new Uint8Array(await vscode.workspace.fs.readFile(this.uri)); 24 | } catch { 25 | this.cache = new Uint8Array(); 26 | } 27 | this._onDidChange.fire({content:this.cache}); 28 | return this.cache; 29 | } 30 | } 31 | 32 | export class PdfViewEditorProvider implements vscode.CustomEditorProvider { 33 | private readonly _onDidChangeCustomDocument = new vscode.EventEmitter>(); 34 | readonly onDidChangeCustomDocument = this._onDidChangeCustomDocument.event; 35 | 36 | constructor(private readonly context:vscode.ExtensionContext) { 37 | this.context = context; 38 | } 39 | 40 | public saveCustomDocument(document: PdfDocument, cancellation: vscode.CancellationToken): Thenable { 41 | return Promise.resolve(); 42 | } 43 | public saveCustomDocumentAs(document: PdfDocument, destination: vscode.Uri, cancellation: vscode.CancellationToken): Thenable { 44 | return Promise.resolve(); 45 | } 46 | public revertCustomDocument(document: PdfDocument, cancellation: vscode.CancellationToken): Thenable { 47 | return Promise.resolve(); 48 | } 49 | public backupCustomDocument(document: PdfDocument, context: vscode.CustomDocumentBackupContext, cancellation: vscode.CancellationToken): Thenable { 50 | return Promise.resolve({id: '', delete: () => {}}); 51 | } 52 | 53 | public async openCustomDocument(uri: vscode.Uri): Promise { 54 | const doc = new PdfDocument(uri); 55 | await doc.refresh(); 56 | return doc; 57 | } 58 | 59 | public async resolveCustomEditor(doc: PdfDocument, webviewPanel: vscode.WebviewPanel): Promise { 60 | EventBus.fire('pdfWillOpenEvent', {uri: doc.uri, doc, webviewPanel}); 61 | 62 | const updateWebview = () => { 63 | if (doc.cache.buffer.byteLength !== 0) { 64 | webviewPanel.webview.postMessage({type:'update', content:doc.cache.buffer}); 65 | } 66 | } 67 | 68 | const docOnDidChangeListener = doc.onDidChange(() => { 69 | updateWebview(); 70 | }); 71 | 72 | webviewPanel.onDidDispose(() => { 73 | docOnDidChangeListener.dispose(); 74 | }); 75 | 76 | webviewPanel.webview.options = {enableScripts:true}; 77 | webviewPanel.webview.html = await this.getHtmlForWebview(webviewPanel.webview); 78 | 79 | // register event listeners 80 | webviewPanel.onDidChangeViewState((e) => { 81 | if (e.webviewPanel.active) { 82 | EventBus.fire('fileWillOpenEvent', {uri: doc.uri}); 83 | } 84 | }); 85 | webviewPanel.webview.onDidReceiveMessage((e) => { 86 | switch (e.type) { 87 | case 'syncPdf': 88 | vscode.commands.executeCommand(`${ROOT_NAME}.compileManager.syncPdf`, e.content); 89 | break; 90 | case 'saveState': 91 | GlobalStateManager.updatePdfViewPersist(this.context, doc.uri.toString(), e.content); 92 | break; 93 | case 'ready': 94 | const state = GlobalStateManager.getPdfViewPersist(this.context, doc.uri.toString()); 95 | const colorThemes = vscode.workspace.getConfiguration('overleaf-workshop.pdfViewer').get('themes', undefined); 96 | webviewPanel.webview.postMessage({type:'initState', content:state, colorThemes}); 97 | updateWebview(); 98 | break; 99 | default: 100 | break; 101 | } 102 | }); 103 | } 104 | 105 | public get triggers(): vscode.Disposable[] { 106 | return [ 107 | vscode.window.registerCustomEditorProvider(`${ROOT_NAME}.pdfViewer`, this, { 108 | webviewOptions: { 109 | retainContextWhenHidden: true, 110 | }, 111 | supportsMultipleEditorsPerDocument: false, 112 | }), 113 | ]; 114 | } 115 | 116 | private patchViewerHtml(webview: vscode.Webview, html: string): string { 117 | const patchPath = (...path:string[]) => webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'views/pdf-viewer', ...path)).toString(); 118 | 119 | // adjust original path 120 | html = html.replace('../build/pdf.js', patchPath('vendor','build','pdf.js')); 121 | html = html.replace('viewer.css', patchPath('vendor','web','viewer.css')); 122 | html = html.replace('viewer.js', patchPath('vendor','web','viewer.js')); 123 | 124 | // patch custom files 125 | const workerScript = ``; 126 | const customScript = ``; 127 | const customStyle = ``; 128 | html = html.replace(/\<\/head\>/, `${workerScript}\n${customScript}\n${customStyle}\n`); 129 | 130 | return html; 131 | } 132 | 133 | private async getHtmlForWebview(webview: vscode.Webview): Promise { 134 | const htmlPath = vscode.Uri.joinPath(this.context.extensionUri, 'views/pdf-viewer/vendor/web/viewer.html'); 135 | let html = (await vscode.workspace.fs.readFile(htmlPath)).toString(); 136 | return this.patchViewerHtml(webview, html); 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ROOT_NAME, ELEGANT_NAME } from './consts'; 3 | 4 | import { RemoteFileSystemProvider, VirtualFileSystem } from './core/remoteFileSystemProvider'; 5 | import { ProjectManagerProvider } from './core/projectManagerProvider'; 6 | import { PdfViewEditorProvider } from './core/pdfViewEditorProvider'; 7 | import { CompileManager } from './compile/compileManager'; 8 | import { LangIntellisenseProvider } from './intellisense'; 9 | import { LocalReplicaSCMProvider } from './scm/localReplicaSCM'; 10 | 11 | export function activate(context: vscode.ExtensionContext) { 12 | // Register: [core] RemoteFileSystemProvider 13 | const remoteFileSystemProvider = new RemoteFileSystemProvider(context); 14 | context.subscriptions.push( ...remoteFileSystemProvider.triggers ); 15 | 16 | // Register: [core] ProjectManagerProvider on Activitybar 17 | const projectManagerProvider = new ProjectManagerProvider(context); 18 | context.subscriptions.push( ...projectManagerProvider.triggers ); 19 | 20 | // Register: [core] PdfViewEditorProvider 21 | const pdfViewEditorProvider = new PdfViewEditorProvider(context); 22 | context.subscriptions.push( ...pdfViewEditorProvider.triggers ); 23 | 24 | // Register: [compile] CompileManager on Statusbar 25 | const compileManager = new CompileManager(remoteFileSystemProvider); 26 | context.subscriptions.push( ...compileManager.triggers ); 27 | 28 | // Register: [intellisense] LangIntellisenseProvider 29 | const langIntellisenseProvider = new LangIntellisenseProvider(context, remoteFileSystemProvider); 30 | context.subscriptions.push( ...langIntellisenseProvider.triggers ); 31 | 32 | // activate vfs for local replica 33 | LocalReplicaSCMProvider.readSettings() 34 | .then(async setting => { 35 | if (setting?.uri) { 36 | const uri = vscode.Uri.parse(setting.uri); 37 | if (uri.scheme===ROOT_NAME) { 38 | // activate vfs 39 | const vfs = (await (await vscode.commands.executeCommand('remoteFileSystem.prefetch', uri))) as VirtualFileSystem; 40 | await vfs.init(); 41 | vscode.commands.executeCommand('setContext', `${ROOT_NAME}.activate`, true); 42 | // activate compile & preview 43 | if (setting?.enableCompileNPreview) { 44 | vscode.commands.executeCommand('setContext', `${ROOT_NAME}.activateCompile`, true); 45 | } 46 | } 47 | } 48 | }); 49 | } 50 | 51 | export function deactivate() { 52 | vscode.commands.executeCommand('setContext', `${ROOT_NAME}.activate`, false); 53 | vscode.commands.executeCommand('setContext', `${ROOT_NAME}.activateCompile`, false); 54 | } -------------------------------------------------------------------------------- /src/intellisense/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ROOT_NAME } from '../consts'; 3 | import { RemoteFileSystemProvider } from '../core/remoteFileSystemProvider'; 4 | 5 | export function fuzzyFilter(list: T[], target: string, keys?: (keyof T)[]) { 6 | const fuzzysearch = require('fuzzysearch'); 7 | // if list is string[] 8 | if (typeof list[0] === 'string') { 9 | return list.filter(item => fuzzysearch(target, item)); 10 | } else { 11 | const _keys = keys ?? Object.keys(list[0]) as (keyof T)[]; 12 | return list.filter(item => _keys.some(key => fuzzysearch(target, item[key]))); 13 | } 14 | } 15 | 16 | export abstract class IntellisenseProvider { 17 | protected selector = {scheme:ROOT_NAME}; 18 | protected abstract readonly contextPrefix: string[][]; 19 | 20 | constructor(protected readonly vfsm: RemoteFileSystemProvider) {} 21 | abstract get triggers(): vscode.Disposable[]; 22 | 23 | protected get contextRegex() { 24 | const prefix = this.contextPrefix 25 | .map(group => `\\\\(${group.join('|')})`) 26 | .join('|'); 27 | const postfix = String.raw`(\[[^\]]*\])*\{([^\}\$]*)\}?`; 28 | return new RegExp(`(?:${prefix})` + postfix); 29 | } 30 | } 31 | 32 | export { LangIntellisenseProvider } from './langIntellisenseProvider'; 33 | -------------------------------------------------------------------------------- /src/intellisense/langIntellisenseProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ROOT_NAME } from '../consts'; 3 | import { RemoteFileSystemProvider } from '../core/remoteFileSystemProvider'; 4 | 5 | import { IntellisenseProvider } from '.'; 6 | import { TexDocumentSymbolProvider } from './texDocumentSymbolProvider'; 7 | import { TexDocumentFormatProvider } from './texDocumentFormatProvider'; 8 | import { MisspellingCheckProvider } from './langMisspellingCheckProvider'; 9 | import { CommandCompletionProvider, ConstantCompletionProvider, FilePathCompletionProvider, ReferenceCompletionProvider } from './langCompletionProvider'; 10 | 11 | export class LangIntellisenseProvider { 12 | private status: vscode.StatusBarItem; 13 | private providers: IntellisenseProvider[]; 14 | 15 | constructor(context: vscode.ExtensionContext, private readonly vfsm: RemoteFileSystemProvider) { 16 | const texSymbolProvider = new TexDocumentSymbolProvider(vfsm); 17 | this.providers = [ 18 | // document symbol provider 19 | texSymbolProvider, 20 | // document format provider 21 | new TexDocumentFormatProvider(vfsm), 22 | // completion provider 23 | new CommandCompletionProvider(vfsm, context.extensionUri), 24 | new ConstantCompletionProvider(vfsm, context.extensionUri), 25 | new FilePathCompletionProvider(vfsm), 26 | new ReferenceCompletionProvider(vfsm, texSymbolProvider), 27 | // misspelling check provider 28 | new MisspellingCheckProvider(vfsm), 29 | ]; 30 | this.status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, -2); 31 | this.activate(); 32 | } 33 | 34 | async activate() { 35 | const uri = vscode.workspace.workspaceFolders?.[0].uri; 36 | if (uri?.scheme!==ROOT_NAME) { return; } 37 | 38 | const vfs = uri && await this.vfsm.prefetch(uri); 39 | const languageItem = vfs?.getSpellCheckLanguage(); 40 | if (languageItem) { 41 | const {name, code} = languageItem; 42 | this.status.text = code===''? '$(eye-closed)' : '$(eye) ' + code.toLocaleUpperCase(); 43 | this.status.tooltip = new vscode.MarkdownString(`${vscode.l10n.t('Spell Check')}: **${name}**`); 44 | this.status.tooltip.appendMarkdown(`\n\n*${vscode.l10n.t('Click to manage spell check.')}*`); 45 | } else { 46 | this.status.text = ''; 47 | this.status.tooltip = ''; 48 | } 49 | this.status.command = 'langIntellisense.settings'; 50 | this.status.show(); 51 | setTimeout(this.activate.bind(this), 200); 52 | } 53 | 54 | get triggers() { 55 | return [ 56 | // register provider triggers 57 | ...this.providers.map(x => x.triggers).flat(), 58 | ]; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/intellisense/langMisspellingCheckProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { IntellisenseProvider } from '.'; 3 | import { ROOT_NAME } from '../consts'; 4 | import { VirtualFileSystem } from '../core/remoteFileSystemProvider'; 5 | import { EventBus } from '../utils/eventBus'; 6 | 7 | function* sRange(start:number, end:number) { 8 | for (let i = start; i <= end; i++) { 9 | yield i; 10 | } 11 | } 12 | 13 | export class MisspellingCheckProvider extends IntellisenseProvider implements vscode.CodeActionProvider { 14 | private learnedWords?: Set; 15 | private suggestionCache: Map = new Map(); 16 | private diagnosticCollection = vscode.languages.createDiagnosticCollection(ROOT_NAME); 17 | protected readonly contextPrefix = []; 18 | 19 | private splitText(text: string) { 20 | return text.split(/([\P{L}\p{N}]*\\[a-zA-Z]*|[\P{L}\p{N}]+)/gu); 21 | } 22 | 23 | private async check(uri:vscode.Uri, changedText: string) { 24 | // init learned words 25 | if (this.learnedWords===undefined) { 26 | const vfs = await this.vfsm.prefetch(uri); 27 | const words = vfs.getDictionary(); 28 | this.learnedWords = new Set(words); 29 | } 30 | 31 | // extract words 32 | const splits = this.splitText(changedText); 33 | const words = splits.filter((x, i) => i%2===0 && x.length>1) 34 | .filter(x => !this.suggestionCache.has(x)) 35 | .filter(x => !this.learnedWords?.has(x)); 36 | if (words.length === 0) { return; } 37 | const uniqueWords = new Set(words); 38 | const uniqueWordsArray = [...uniqueWords]; 39 | 40 | // update suggestion cache and learned words 41 | const vfs = await this.vfsm.prefetch(uri); 42 | const misspellings = await vfs.spellCheck(uri, uniqueWordsArray); 43 | if (misspellings) { 44 | misspellings.forEach(misspelling => { 45 | uniqueWords.delete(uniqueWordsArray[misspelling.index]); 46 | this.suggestionCache.set(uniqueWordsArray[misspelling.index], misspelling.suggestions); 47 | }); 48 | } 49 | uniqueWords.forEach(x => this.learnedWords?.add(x)); 50 | 51 | // restrict cache size 52 | if (this.suggestionCache.size > 1000) { 53 | const keys = [...this.suggestionCache.keys()]; 54 | keys.slice(0, 100).forEach(key => this.suggestionCache.delete(key)); 55 | } 56 | } 57 | 58 | private async updateDiagnostics(uri:vscode.Uri, range?: vscode.Range) { 59 | // remove affected diagnostics 60 | let diagnostics = this.diagnosticCollection.get(uri) || []; 61 | if (range===undefined) { 62 | diagnostics = []; 63 | } else { 64 | diagnostics = diagnostics.filter(x => !x.range.intersection(range)); 65 | } 66 | 67 | // update diagnostics 68 | const newDiagnostics:vscode.Diagnostic[] = []; 69 | const document = await vscode.workspace.openTextDocument(uri); 70 | const startLine = range ? range.start.line : 0; 71 | const endLine = range ? range.end.line : document.lineCount-1; 72 | for (const i of sRange(startLine, endLine)) { 73 | const cumsum = (sum => (value: number) => sum += value)(0); 74 | const splits = this.splitText( document.lineAt(i).text ); 75 | const splitStart = splits.map(x => cumsum(x.length)); 76 | const words = splits.filter((_, i) => i%2===0); 77 | const wordEnds = splitStart.filter((_, i) => i%2===0); 78 | // 79 | words.forEach((word, j) => { 80 | if (this.suggestionCache.has(word)) { 81 | const range = new vscode.Range( 82 | new vscode.Position(i, wordEnds[j] - word.length), 83 | new vscode.Position(i, wordEnds[j]) 84 | ); 85 | const message = vscode.l10n.t('{word}: Unknown word.', {word}); 86 | const diagnostic = new vscode.Diagnostic(range, message, vscode.DiagnosticSeverity.Information); 87 | diagnostic.source = vscode.l10n.t('Spell Checker'); 88 | diagnostic.code = word; 89 | newDiagnostics.push(diagnostic); 90 | } 91 | }); 92 | } 93 | // update diagnostics collection 94 | diagnostics = [...diagnostics, ...newDiagnostics]; 95 | this.diagnosticCollection.set(uri, diagnostics); 96 | } 97 | 98 | private resetDiagnosticCollection() { 99 | this.diagnosticCollection.clear(); 100 | vscode.workspace.textDocuments.forEach(async doc => { 101 | const uri = doc.uri; 102 | await this.check( uri, doc.getText() ); 103 | this.updateDiagnostics(uri); 104 | }); 105 | } 106 | 107 | provideCodeActions(document: vscode.TextDocument, range: vscode.Range, context: vscode.CodeActionContext, token: vscode.CancellationToken): vscode.ProviderResult { 108 | if (context.diagnostics.length === 0) { 109 | return []; 110 | } 111 | 112 | const diagnostic = context.diagnostics[0]; 113 | const actions = this.suggestionCache.get(diagnostic.code as string) 114 | ?.slice(0,8).map(suggestion => { 115 | const action = new vscode.CodeAction(suggestion, vscode.CodeActionKind.QuickFix); 116 | action.diagnostics = [diagnostic]; 117 | action.edit = new vscode.WorkspaceEdit(); 118 | action.edit.replace(document.uri, diagnostic.range, suggestion); 119 | return action; 120 | }); 121 | // 122 | const learnAction = new vscode.CodeAction(vscode.l10n.t('Add to Dictionary'), vscode.CodeActionKind.QuickFix); 123 | learnAction.diagnostics = [diagnostic]; 124 | learnAction.command = { 125 | title: vscode.l10n.t('Add to Dictionary'), 126 | command: 'langIntellisense.learnSpelling', 127 | arguments: [document.uri, diagnostic.code as string], 128 | }; 129 | actions?.push(learnAction); 130 | // 131 | return actions; 132 | } 133 | 134 | learnSpelling(uri:vscode.Uri, word: string) { 135 | this.vfsm.prefetch(uri).then(vfs => vfs.spellLearn(word)); 136 | this.learnedWords?.add(word); 137 | this.suggestionCache.delete(word); 138 | this.updateDiagnostics(uri); 139 | } 140 | 141 | async dictionarySettings(vfs:VirtualFileSystem, dictionary?:string[]) { 142 | vscode.window.showQuickPick(dictionary||[], { 143 | canPickMany: false, 144 | placeHolder: vscode.l10n.t('Select a word to unlearn'), 145 | }).then(async (word) => { 146 | if (word) { 147 | vfs.spellUnlearn(word); 148 | this.learnedWords?.delete(word); 149 | this.suggestionCache.delete(word); 150 | dictionary = dictionary?.filter(x => x!==word); 151 | this.dictionarySettings(vfs, dictionary); 152 | } else { 153 | // reset diagnostic collection is dictionary changed 154 | if ( !vfs.getDictionary()?.every(x => dictionary?.includes(x)) ) { 155 | this.resetDiagnosticCollection(); 156 | } 157 | } 158 | }); 159 | } 160 | 161 | async spellCheckSettings() { 162 | const uri = vscode.workspace.workspaceFolders?.[0].uri; 163 | const vfs = uri && await this.vfsm.prefetch(uri); 164 | const languages = vfs?.getAllSpellCheckLanguages(); 165 | const currentLanguage = vfs?.getSpellCheckLanguage(); 166 | 167 | const items = []; 168 | items.push({ 169 | id: "dictionary", 170 | label: vscode.l10n.t('Manage Dictionary'), 171 | iconPath: new vscode.ThemeIcon('book'), 172 | }); 173 | items.push({label:'',kind:vscode.QuickPickItemKind.Separator}); 174 | for (const item of languages||[]) { 175 | items.push({ 176 | label: item.name, 177 | description: item.code, 178 | picked: item.code===currentLanguage?.code, 179 | }); 180 | } 181 | 182 | vscode.window.showQuickPick(items, { 183 | placeHolder: vscode.l10n.t('Select spell check language'), 184 | canPickMany: false, 185 | ignoreFocusOut: true, 186 | matchOnDescription: true, 187 | matchOnDetail: true, 188 | }).then(async (option) => { 189 | if (option?.id==='dictionary') { 190 | vfs && this.dictionarySettings(vfs, vfs.getDictionary()); 191 | } else { 192 | option && vfs?.updateSettings({spellCheckLanguage:option.description}); 193 | } 194 | }); 195 | } 196 | 197 | get triggers () { 198 | return [ 199 | // the diagnostic collection 200 | this.diagnosticCollection, 201 | // the code action provider 202 | vscode.languages.registerCodeActionsProvider(this.selector, this), 203 | // register learn spelling command 204 | vscode.commands.registerCommand('langIntellisense.learnSpelling', (uri: vscode.Uri, word: string) => { 205 | this.learnSpelling(uri, word); 206 | }), 207 | vscode.commands.registerCommand('langIntellisense.settings', () => { 208 | this.spellCheckSettings(); 209 | }), 210 | // reset diagnostics when spell check languages changed 211 | EventBus.on('spellCheckLanguageUpdateEvent', async () => { 212 | this.learnedWords?.clear(); 213 | this.suggestionCache.clear(); 214 | this.resetDiagnosticCollection(); 215 | }), 216 | // update diagnostics on document open 217 | vscode.workspace.onDidOpenTextDocument(async doc => { 218 | if (doc.uri.scheme === ROOT_NAME) { 219 | const uri = doc.uri; 220 | await this.check( uri, doc.getText() ); 221 | this.updateDiagnostics(uri); 222 | } 223 | }), 224 | // update diagnostics on text changed 225 | vscode.workspace.onDidChangeTextDocument(async e => { 226 | if (e.document.uri.scheme === ROOT_NAME) { 227 | const uri = e.document.uri; 228 | for (const event of e.contentChanges) { 229 | // extract changed text 230 | const startLine = Math.min(0, event.range.start.line-1); 231 | const [endLine, maxLength] = (() => { 232 | try { 233 | const _line = event.range.end.line; 234 | return [_line, e.document.lineAt(_line).text.length]; 235 | } catch { 236 | return [event.range.end.line+1, 0]; 237 | } 238 | })(); 239 | let _range = new vscode.Range(startLine, 0, endLine, maxLength); 240 | _range = e.document.validateRange(_range); 241 | // update diagnostics 242 | const changedText = [...sRange(_range.start.line, _range.end.line)] 243 | .map(i => e.document.lineAt(i).text).join(' '); 244 | await this.check( uri, changedText ); 245 | this.updateDiagnostics(uri, _range); 246 | }; 247 | } 248 | }), 249 | ]; 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/intellisense/texDocumentFormatProvider.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as vscode from 'vscode'; 3 | import * as Prettier from "prettier"; 4 | import { prettierPluginLatex } from "@unified-latex/unified-latex-prettier"; 5 | import { IntellisenseProvider } from '.'; 6 | 7 | // https://github.com/siefkenj/latex-parser-playground/blob/master/src/async-worker/parsing-worker.ts#L35-L43 8 | async function prettierFormat(text: string, options: vscode.FormattingOptions ) { 9 | 10 | const lineBreakEnabled = vscode.workspace.getConfiguration('overleaf-workshop.formatWithLineBreak').get('enabled', true); 11 | const printWidth = lineBreakEnabled ? 80 : 10000; 12 | return Prettier.format(text, { 13 | parser: "latex-parser", 14 | tabWidth: options.tabSize, 15 | useTabs: !(options.insertSpaces), 16 | plugins: [prettierPluginLatex], 17 | printWidth: printWidth, 18 | }); 19 | } 20 | 21 | export class TexDocumentFormatProvider extends IntellisenseProvider implements vscode.DocumentFormattingEditProvider { 22 | protected readonly contextPrefix = []; 23 | 24 | provideDocumentFormattingEdits(document: vscode.TextDocument, options: vscode.FormattingOptions, token: vscode.CancellationToken): vscode.ProviderResult { 25 | const text = document.getText(); 26 | return prettierFormat(text, options).then(formattedText => { 27 | // Create a TextEdit to replace the entire document text with the formatted text 28 | const edit = new vscode.TextEdit(new vscode.Range(0, 0, document.lineCount, 0), formattedText); 29 | return [edit]; 30 | }); 31 | } 32 | 33 | provideDocumentRangeFormattingEdits(document: vscode.TextDocument, range: vscode.Range, options: vscode.FormattingOptions, token: vscode.CancellationToken): vscode.ProviderResult { 34 | const text = document.getText(range); 35 | return prettierFormat(text, options).then(formattedText => { 36 | // Create a TextEdit to replace the selected text with the formatted text 37 | const edit = new vscode.TextEdit(range, formattedText); 38 | return [edit]; 39 | }); 40 | } 41 | 42 | get triggers() { 43 | const latexSelector = ['latex', 'latex-expl3', 'pweave', 'jlweave', 'rsweave'].map(id => { 44 | return {...this.selector, language: id }; 45 | }); 46 | 47 | return[ 48 | vscode.languages.registerDocumentFormattingEditProvider(latexSelector, this), 49 | vscode.languages.registerDocumentRangeFormattingEditProvider(latexSelector, this), 50 | ]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/intellisense/texDocumentParseUtility.ts: -------------------------------------------------------------------------------- 1 | import type * as Ast from '@unified-latex/unified-latex-types'; 2 | import * as unifiedLaTeXParse from '@unified-latex/unified-latex-util-parse'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/naming-convention 5 | export enum TeXElementType { Environment, Command, Section, SectionAst, SubFile, BibItem, BibField, BibFile}; 6 | 7 | export type TeXElement = { 8 | readonly type: TeXElementType, 9 | readonly name: string, 10 | label: string, 11 | readonly lineFr: number, 12 | lineTo: number, 13 | children: TeXElement[], 14 | parent?: TeXElement, 15 | appendix?: boolean, 16 | }; 17 | 18 | // Initialize the parser 19 | const unifiedParser: { parse: (content: string) => Ast.Root } = unifiedLaTeXParse.getParser({ 20 | flags: { autodetectExpl3AndAtLetter: true }, 21 | macros: { 22 | addbibresource: { // Takes one mandatory argument for biblatex 23 | signature: 'm', 24 | } 25 | } 26 | }); 27 | 28 | // Env that matches the defaultStructure will be treated as a section with top-down order 29 | const defaultStructure = ["book", "part", "chapter", "section", "subsection", "subsubsection", "paragraph", "subparagraph"]; 30 | 31 | 32 | // Match label command 33 | const defaultCMD = ["label"]; 34 | 35 | /* 36 | * Convert a macro to string 37 | * 38 | * @param macro: the macro to be converted 39 | * @return: the string representation of the macro 40 | */ 41 | // reference: https://github.com/James-Yu/LaTeX-Workshop/blob/9cb158f57b73f3e506b2874ffe9dbce6a24127b8/src/utils/parser.ts#L3 42 | function macroToStr(macro: Ast.Macro): string { 43 | if (macro.content === 'texorpdfstring') { 44 | return (macro.args?.[1].content[0] as Ast.String | undefined)?.content || ''; 45 | } 46 | return `\\${macro.content}` + (macro.args?.map(arg => `${arg.openMark}${argContentToStr(arg.content)}${arg.closeMark}`).join('') ?? ''); 47 | } 48 | 49 | /* 50 | * Convert an environment to string 51 | * 52 | * @param env: the environment to be converted 53 | * @return: the string representation of the environment 54 | */ 55 | // reference: https://github.com/James-Yu/LaTeX-Workshop/blob/9cb158f57b73f3e506b2874ffe9dbce6a24127b8/src/utils/parser.ts#L10 56 | function envToStr(env: Ast.Environment | Ast.VerbatimEnvironment): string { 57 | return `\\environment{${env.env}}`; 58 | } 59 | 60 | /* 61 | * Convert the content of an argument to string 62 | * 63 | * @param argContent: the content of the argument 64 | * @param preserveCurlyBrace: whether to preserve the curly brace '{' 65 | * @return: the string representation of the argument 66 | */ 67 | // reference: https://github.com/James-Yu/LaTeX-Workshop/blob/9cb158f57b73f3e506b2874ffe9dbce6a24127b8/src/utils/parser.ts#L14 68 | function argContentToStr(argContent: Ast.Node[], preserveCurlyBrace: boolean = false): string { 69 | return argContent.map(node => { 70 | // Verb 71 | switch (node.type) { 72 | case 'string': 73 | return node.content; 74 | case 'whitespace': 75 | case 'parbreak': 76 | case 'comment': 77 | return ' '; 78 | case 'macro': 79 | return macroToStr(node); 80 | case 'environment': 81 | case 'verbatim': 82 | case 'mathenv': 83 | return envToStr(node); 84 | case 'inlinemath': 85 | return `$${argContentToStr(node.content)}$`; 86 | case 'displaymath': 87 | return `\\[${argContentToStr(node.content)}\\]`; 88 | case 'group': 89 | return preserveCurlyBrace ? `{${argContentToStr(node.content)}}` : argContentToStr(node.content); 90 | case 'verb': 91 | return node.content; 92 | default: 93 | return ''; 94 | } 95 | }).join(''); 96 | } 97 | 98 | /* 99 | * Parse a node and generate a TeXElement, this function travel each node recursively (Here children is referenced by `.content`), 100 | * if the node is a macro and matches the defaultStructure, it will be treated as a section; 101 | * if the node is a macro and matches the defaultCMD, it will be treated as a command; 102 | * if the node is an environment, it will be treated as an environment; 103 | * 104 | * @param node: the node to be parsed 105 | * @param root: the root TeXElement 106 | */ 107 | // reference: https://github.com/James-Yu/LaTeX-Workshop/blob/9cb158f57b73f3e506b2874ffe9dbce6a24127b8/src/outline/structure/latex.ts#L86 108 | async function parseNode( 109 | node: Ast.Node, 110 | root: { children: TeXElement[] } 111 | ) { 112 | const attributes = { 113 | lineFr: (node.position?.start.line ?? 1) - 1, 114 | lineTo: (node.position?.end.line ?? 1) - 1, 115 | children: [] 116 | }; 117 | let element: TeXElement | undefined; 118 | let caption = ''; 119 | switch (node.type) { 120 | case 'macro': 121 | let caseType = ''; 122 | if (defaultStructure.includes(node.content) && node.args?.[node.args?.length - 1].openMark === '{') { 123 | caseType = 'section'; 124 | } else if (defaultCMD.includes(node.content)) { 125 | caseType = 'label'; 126 | } else if (node.content === 'input') { 127 | caseType = 'subFile'; 128 | } else if (node.content === 'bibliography' || node.content === 'addbibresource') { 129 | caseType = 'bibFile'; 130 | } 131 | let argStr = ''; 132 | switch (caseType) { 133 | case 'section': 134 | element = { 135 | type: node.args?.[0]?.content[0] 136 | ? TeXElementType.SectionAst 137 | : TeXElementType.Section, 138 | name: node.content, 139 | label: argContentToStr( 140 | ((node.args?.[1]?.content?.length ?? 0) > 0 141 | ? node.args?.[1]?.content 142 | : node.args?.[node.args?.length - 1]?.content) || [] 143 | ), 144 | ...attributes 145 | }; 146 | break; 147 | case 'label': 148 | argStr = argContentToStr(node.args?.[2]?.content || []); 149 | element = { 150 | type: TeXElementType.Command, 151 | name: node.content, 152 | label: `${node.content}` + (argStr ? `: ${argStr}` : ''), 153 | ...attributes 154 | }; 155 | break; 156 | case 'subFile': 157 | argStr = argContentToStr(node.args?.[0]?.content || []); 158 | element = { 159 | type: TeXElementType.SubFile, 160 | name: node.content, 161 | label: argStr ? `${argStr}` : '', 162 | ...attributes 163 | }; 164 | break; 165 | case 'bibFile': 166 | argStr = argContentToStr(node.args?.[0]?.content || []); 167 | element = { 168 | type: TeXElementType.BibFile, 169 | name: node.content, 170 | label: argStr ? `${argStr}` : '', 171 | ...attributes 172 | }; 173 | break; 174 | } 175 | break; 176 | case 'environment': 177 | switch (node.env) { 178 | case 'frame': 179 | const frameTitleMacro: Ast.Macro | undefined = node.content.find( 180 | sub => sub.type === 'macro' && sub.content === 'frametitle' 181 | ) as Ast.Macro | undefined; 182 | caption = argContentToStr(node.args?.[3]?.content || []) || 183 | argContentToStr(frameTitleMacro?.args?.[3]?.content || []); 184 | element = { 185 | type: TeXElementType.Environment, 186 | name: node.env, 187 | label: `${node.env.charAt(0).toUpperCase()}${node.env.slice(1)}` + 188 | (caption ? `: ${caption}` : ''), 189 | ...attributes 190 | }; 191 | break; 192 | 193 | case 'figure': 194 | case 'figure*': 195 | case 'table': 196 | case 'table*': 197 | const captionMacro: Ast.Macro | undefined = node.content.find( 198 | sub => sub.type === 'macro' && sub.content === 'caption' 199 | ) as Ast.Macro | undefined; 200 | caption = argContentToStr(captionMacro?.args?.[1]?.content || []); 201 | if (node.env.endsWith('*')) { 202 | node.env = node.env.slice(0, -1); 203 | } 204 | element = { 205 | type: TeXElementType.Environment, 206 | name: node.env, 207 | label: `${node.env.charAt(0).toUpperCase()}${node.env.slice(1)}` + 208 | (caption ? `: ${caption}` : ''), 209 | ...attributes 210 | }; 211 | break; 212 | 213 | case 'macro': 214 | case 'environment': 215 | default: 216 | if (defaultStructure.includes(node.env)) { 217 | const caption = (node.content[0] as Ast.Group | undefined)?.content[0] as Ast.String | undefined; 218 | element = { 219 | type: TeXElementType.Environment, 220 | name: node.env, 221 | label: `${node.env.charAt(0).toUpperCase()}${node.env.slice(1)}` + 222 | (caption ? `: ${caption.content}` : ''), 223 | ...attributes 224 | }; 225 | } else { 226 | element = { 227 | type: TeXElementType.Environment, 228 | name: node.env, 229 | label: `${node.env.charAt(0).toUpperCase()}${node.env.slice(1)}`, 230 | ...attributes 231 | }; 232 | } 233 | break; 234 | } 235 | break; 236 | case 'mathenv': 237 | switch (node.env) { 238 | case 'string': 239 | element = { 240 | type: TeXElementType.Environment, 241 | name: node.env, 242 | label: `${node.env.charAt(0).toUpperCase()}${node.env.slice(1)}`, 243 | ...attributes 244 | }; 245 | break; 246 | } 247 | break; 248 | } 249 | 250 | if (element !== undefined) { 251 | root.children.push(element); 252 | root = element; 253 | } 254 | 255 | if ('content' in node && typeof node.content !== 'string') { 256 | for (const sub of node.content) { 257 | if (['string', 'parbreak', 'whitespace'].includes(sub.type)) { 258 | continue; 259 | } 260 | await parseNode(sub, root); 261 | } 262 | } 263 | } 264 | 265 | /* 266 | * Recursively format a tree-like TeXElement[] structure based on the given order in defaultStructure 267 | * 268 | * @param parentStruct: the parent TeXElement[] structure 269 | * @return: the formatted TeXElement[] structure with children fulfilled 270 | */ 271 | function hierarchyStructFormat(parentStruct: TeXElement[]): TeXElement[] { 272 | const resStruct: TeXElement[] = []; 273 | const orderFromDefaultStructure = new Map(); 274 | for (const [i, section] of defaultStructure.entries()) { 275 | orderFromDefaultStructure.set(section, i); 276 | } 277 | 278 | let prev: TeXElement | undefined; 279 | for (const section of parentStruct) { 280 | // Root Node 281 | if (prev === undefined) { 282 | resStruct.push(section); 283 | prev = section; 284 | continue; 285 | } 286 | // Comparing the order of current section and previous section from defaultStructure 287 | if ((orderFromDefaultStructure.get(section.name) ?? defaultStructure.length) > (orderFromDefaultStructure.get(prev.name) ?? defaultStructure.length)) { 288 | prev.children.push(section); 289 | } else { 290 | resStruct.push(section); 291 | prev.children = hierarchyStructFormat(prev.children); 292 | prev = section; 293 | } 294 | } 295 | 296 | if (prev !== undefined) { 297 | prev.children = hierarchyStructFormat(prev.children); 298 | } 299 | return resStruct; 300 | } 301 | 302 | /* 303 | * Generate a tree-like structure from a LaTeX file 304 | * 305 | * @param document: the LaTeX file 306 | * @return: the tree-like TeXElement[] structure 307 | */ 308 | // reference: https://github.com/James-Yu/LaTeX-Workshop/blob/master/src/outline/structurelib/latex.ts#L30 309 | export async function genTexElements(documentText: string): Promise { 310 | const resElement = { children: [] as TeXElement[] }; 311 | let ast = unifiedParser.parse(documentText); 312 | for (const node of ast.content) { 313 | if (['string', 'parbreak', 'whitespace'].includes(node.type)) { 314 | continue; 315 | } 316 | try { 317 | await parseNode(node, resElement); 318 | } 319 | catch (e) { 320 | console.error(e); 321 | } 322 | } 323 | 324 | const rootStruct = resElement.children; 325 | const documentTextLines = documentText.split('\n'); 326 | const hierarchyStruct = hierarchyStructFormat(rootStruct); 327 | updateSecMacroLineTo(hierarchyStruct, documentTextLines.length , documentTextLines); 328 | return hierarchyStruct; 329 | } 330 | 331 | /* 332 | * Update the lineTo of each section element in the TeXElement[] structure 333 | * 334 | * @param texElements: the TeXElement[] structure 335 | * @param lastLine: the last lineTo of given TeXElement[] structure 336 | * @param documentTextLines: the content of the LaTeX file used to refine the lineTo 337 | * @return: the updated TeXElement[] structure 338 | */ 339 | function updateSecMacroLineTo(texElements: TeXElement[], lastLine: number, documentTextLines: string[]) { 340 | const secTexElements = texElements.filter(element => element.type === TeXElementType.Section || element.type === TeXElementType.SectionAst); 341 | for (let index = 1; index <= secTexElements.length; index++) { 342 | // LineTo of the previous section is the lineFr of the current section OR the last document line 343 | let lineTo = secTexElements[index]?.lineFr ?? lastLine; 344 | 345 | // Search the non-empty line before the next section 346 | while (lineTo > 1 && documentTextLines[lineTo - 1].trim() === '') { 347 | lineTo--; 348 | } 349 | secTexElements[index - 1].lineTo = lineTo; 350 | if (secTexElements[index - 1].children.length > 0 ){ 351 | updateSecMacroLineTo(secTexElements[index - 1].children, lineTo, documentTextLines); 352 | } 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/intellisense/texDocumentSymbolProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { VirtualFileSystem, parseUri } from '../core/remoteFileSystemProvider'; 3 | import { IntellisenseProvider } from '.'; 4 | import { TeXElement, TeXElementType, genTexElements } from './texDocumentParseUtility'; 5 | import { ROOT_NAME } from '../consts'; 6 | 7 | type TexFileStruct = { 8 | texElements: TeXElement[], 9 | childrenPaths: string[], 10 | bibFilePaths: string[], 11 | }; 12 | 13 | function elementsTypeCast(section: TeXElement): vscode.SymbolKind { 14 | switch (section.type) { 15 | case TeXElementType.Section: 16 | case TeXElementType.SectionAst: 17 | return vscode.SymbolKind.Struct; 18 | case TeXElementType.Environment: 19 | return vscode.SymbolKind.Package; 20 | case TeXElementType.Command: 21 | return vscode.SymbolKind.Number; 22 | case TeXElementType.SubFile: 23 | return vscode.SymbolKind.File; 24 | case TeXElementType.BibItem: 25 | return vscode.SymbolKind.Class; 26 | case TeXElementType.BibField: 27 | return vscode.SymbolKind.Constant; 28 | default: 29 | return vscode.SymbolKind.String; 30 | } 31 | } 32 | 33 | function elementsToSymbols(sections: TeXElement[]): vscode.DocumentSymbol[] { 34 | const symbols: vscode.DocumentSymbol[] = []; 35 | sections.forEach(section => { 36 | const range = new vscode.Range(section.lineFr, 0, section.lineTo, 65535); 37 | const symbol = new vscode.DocumentSymbol( 38 | section.label || 'empty', 39 | '', 40 | elementsTypeCast(section), 41 | range, range); 42 | symbols.push(symbol); 43 | if (section.children.length > 0) { 44 | symbol.children = elementsToSymbols(section.children); 45 | } 46 | }); 47 | return symbols; 48 | } 49 | 50 | function elementsToFoldingRanges(sections: TeXElement[]): vscode.FoldingRange[] { 51 | const foldingRanges: vscode.FoldingRange[] = []; 52 | sections.forEach(section => { 53 | foldingRanges.push(new vscode.FoldingRange(section.lineFr, section.lineTo - 1)); // without the last line, e.g \end{document} 54 | if (section.children.length > 0) { 55 | foldingRanges.push(...elementsToFoldingRanges(section.children)); 56 | } 57 | }); 58 | return foldingRanges; 59 | } 60 | 61 | // Reference: https://github.com/iamhyc/LaTeX-Workshop/commit/d1a078d9b63a34c9cda9ff5d1042c8999030e6e1 62 | function getEnvironmentFoldingRange(document: vscode.TextDocument){ 63 | const ranges: vscode.FoldingRange[] = []; 64 | const opStack: { keyword: string, index: number }[] = []; 65 | const text: string = document.getText(); 66 | const envRegex: RegExp = /(\\(begin){(.*?)})|(\\(end){(.*?)})/g; //to match one 'begin' OR 'end' 67 | 68 | let match = envRegex.exec(text); // init regex search 69 | while (match) { 70 | //for 'begin': match[2] contains 'begin', match[3] contains keyword 71 | //fro 'end': match[5] contains 'end', match[6] contains keyword 72 | const item = { 73 | keyword: match[2] ? match[3] : match[6], 74 | index: match.index 75 | }; 76 | const lastItem = opStack[opStack.length - 1]; 77 | 78 | if (match[5] && lastItem && lastItem.keyword === item.keyword) { // match 'end' with its 'begin' 79 | opStack.pop(); 80 | ranges.push(new vscode.FoldingRange( 81 | document.positionAt(lastItem.index).line, 82 | document.positionAt(item.index).line - 1 83 | )); 84 | } else { 85 | opStack.push(item); 86 | } 87 | 88 | match = envRegex.exec(text); //iterate regex search 89 | } 90 | //TODO: if opStack still not empty 91 | return ranges; 92 | } 93 | 94 | /* 95 | * Convert the file into the struct by: 96 | * 1. Construct child, named as Uri.path, from TeXElementType.SubFile 97 | * 2. Construct bibFile from TeXElementType.BibFile 98 | * 99 | * @param fileContent: file content 100 | */ 101 | async function parseTexFileStruct(fileContent:string): Promise{ 102 | const childrenPaths = []; 103 | const bibFilePaths = []; 104 | const texSymbols = await genTexElements(fileContent); 105 | 106 | // BFS: Traverse the texElements and build fileSymbol 107 | const queue: TeXElement[] = [...texSymbols]; 108 | while (queue.length > 0) { 109 | const symbol = queue.shift(); 110 | switch (symbol?.type) { 111 | case TeXElementType.BibFile: 112 | bibFilePaths.push(symbol.label); 113 | break; 114 | case TeXElementType.SubFile: 115 | const subFilePath = symbol.label?.endsWith('.tex') ? symbol.label : `${symbol.label}.tex`; 116 | childrenPaths.push(subFilePath); 117 | break; 118 | default: 119 | break; 120 | } 121 | // append children to queue 122 | symbol?.children.forEach( child => { 123 | queue.push(child); 124 | }); 125 | } 126 | 127 | return { 128 | texElements: texSymbols, 129 | childrenPaths: childrenPaths, 130 | bibFilePaths: bibFilePaths, 131 | }; 132 | } 133 | 134 | class ProjectStructRecord { 135 | private fileRecordMap: Map = new Map(); 136 | 137 | constructor (private readonly vfs: VirtualFileSystem) {} 138 | 139 | get rootPath(): string { 140 | return this.vfs.getRootDocName(); 141 | } 142 | 143 | async init() { 144 | const rootFileStruct = await this.refreshRecord( this.rootPath ); 145 | const fileQueue: TexFileStruct[] = [ rootFileStruct ]; 146 | 147 | // iteratively traverse file node tree 148 | while (fileQueue.length > 0) { 149 | const fileNode = fileQueue.shift()!; 150 | const subFiles = fileNode.childrenPaths; 151 | for (const subFile of subFiles) { 152 | // Get fileStruct can be failed due to file not exist 153 | try { 154 | const fileStruct = await this.refreshRecord(subFile); 155 | fileQueue.push( fileStruct ); 156 | } catch { continue; } 157 | }; 158 | } 159 | } 160 | 161 | getTexFileStruct(document: vscode.TextDocument): TexFileStruct | undefined { 162 | const filePath = document.fileName; 163 | return this.fileRecordMap.get(filePath); 164 | } 165 | 166 | async refreshRecord(source: vscode.TextDocument | string): Promise { 167 | let filePath:string, content: string; 168 | // get file path and content 169 | if (typeof source === 'string') { 170 | const uri = this.vfs.pathToUri(source); 171 | filePath = source; 172 | content = new TextDecoder().decode( await this.vfs.openFile(uri) ); 173 | } else { 174 | filePath = source.fileName; 175 | content = source.getText(); 176 | } 177 | // update file record 178 | const fileStruct = await parseTexFileStruct( content ); 179 | this.fileRecordMap.set(filePath, fileStruct); 180 | return fileStruct; 181 | } 182 | 183 | getAllBibFilePaths(): string[] { 184 | const rootStruct = this.fileRecordMap.get( this.rootPath ); 185 | if (rootStruct === undefined) { return []; } 186 | 187 | const queue = [rootStruct]; 188 | const bibFilePaths: string[] = []; 189 | // iteratively traverse file node tree 190 | while (queue.length > 0) { 191 | const item = queue.shift()!; 192 | const paths = item.bibFilePaths.flatMap( name => (name.split(',') ?? []) ) 193 | .map( name => (name.endsWith('.bib') ? name : `${name}.bib`) ); 194 | bibFilePaths.push(...paths); 195 | // append children to queue 196 | item.childrenPaths.forEach( child => { 197 | const childItem = this.fileRecordMap.get(child); 198 | childItem && queue.push(childItem); 199 | }); 200 | } 201 | return bibFilePaths; 202 | } 203 | } 204 | 205 | export class TexDocumentSymbolProvider extends IntellisenseProvider implements vscode.DocumentSymbolProvider, vscode.FoldingRangeProvider { 206 | protected readonly contextPrefix = []; 207 | 208 | private projectRecordMap = new Map(); 209 | 210 | async provideFoldingRanges(document: vscode.TextDocument, context: vscode.FoldingContext, token: vscode.CancellationToken): Promise { 211 | const environmentRange = getEnvironmentFoldingRange(document); 212 | 213 | // Try get fileStruct 214 | const {projectName} = parseUri(document.uri); 215 | let projectRecord = this.projectRecordMap.get(projectName); 216 | const fileStruct = projectRecord?.getTexFileStruct(document); 217 | 218 | return environmentRange.concat( fileStruct ? elementsToFoldingRanges(fileStruct.texElements) : [] ); 219 | } 220 | 221 | async provideDocumentSymbols(document: vscode.TextDocument): Promise { 222 | const vfs = await this.vfsm.prefetch(document.uri); 223 | const {projectName} = parseUri(document.uri); 224 | 225 | // init project record if not exist 226 | let projectRecord = this.projectRecordMap.get(projectName); 227 | if (projectRecord === undefined) { 228 | projectRecord = new ProjectStructRecord(vfs); 229 | await projectRecord.init(); 230 | this.projectRecordMap.set(projectName, projectRecord); 231 | } 232 | 233 | // return symbols 234 | const fileStruct = projectRecord.getTexFileStruct(document) ?? await projectRecord.refreshRecord(document); 235 | return elementsToSymbols( fileStruct.texElements ); 236 | } 237 | 238 | get currentBibPathArray(): string[] { 239 | // check if supported vfs 240 | const uri = vscode.window.activeTextEditor?.document.uri; 241 | if (uri?.scheme !== ROOT_NAME) { return []; } 242 | // get bib file paths 243 | const {projectName} = parseUri(uri); 244 | const projectRecord = this.projectRecordMap.get(projectName); 245 | return projectRecord?.getAllBibFilePaths() ?? []; 246 | } 247 | 248 | get triggers(): vscode.Disposable[] { 249 | const latexSelector = ['latex', 'latex-expl3', 'pweave', 'jlweave', 'rsweave'].map((id) => { 250 | return {...this.selector, language: id }; 251 | }); 252 | return [ 253 | // register symbol provider 254 | vscode.languages.registerDocumentSymbolProvider(latexSelector, this), 255 | // register folding range provider 256 | vscode.languages.registerFoldingRangeProvider(latexSelector, this), 257 | // register file change listener 258 | vscode.workspace.onDidChangeTextDocument(async (e) => { 259 | const {projectName} = parseUri(e.document.uri); 260 | const projectRecord = this.projectRecordMap.get(projectName); 261 | projectRecord?.refreshRecord(e.document); 262 | }), 263 | ]; 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/scm/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { VirtualFileSystem } from "../core/remoteFileSystemProvider"; 3 | import { EventBus } from "../utils/eventBus"; 4 | 5 | export interface SettingItem extends vscode.QuickPickItem { 6 | callback: () => Promise; 7 | } 8 | 9 | export interface StatusInfo { 10 | status: 'push' | 'pull' | 'idle' | 'need-attention', 11 | message?: string, 12 | } 13 | 14 | export interface CommitTag { 15 | comment: string, 16 | username: string, 17 | email: string, 18 | timestamp: number, 19 | } 20 | 21 | export interface FileEditOps { 22 | pathname: string, 23 | ops: { 24 | type: 'add' | 'delete', 25 | start: number, 26 | end: number, 27 | content: string, 28 | }[], 29 | } 30 | 31 | export interface CommitInfo { 32 | version: string, 33 | username: string, 34 | email: string, 35 | timestamp: number, 36 | commitMessage: string, 37 | tags: CommitTag[], 38 | diff: FileEditOps[], 39 | } 40 | 41 | export class CommitItem { 42 | constructor(public commit: CommitInfo) {} 43 | 44 | isEqualTo(other: CommitItem) { 45 | const otherCommit = other.commit; 46 | return ( 47 | this.commit.username === otherCommit.username 48 | && this.commit.email === otherCommit.email 49 | && this.commit.timestamp === otherCommit.timestamp 50 | && this.commit.commitMessage === otherCommit.commitMessage 51 | ); 52 | } 53 | } 54 | 55 | export abstract class BaseSCM { 56 | private _status: StatusInfo = {status: 'idle', message: ''}; 57 | public static readonly label: string; 58 | 59 | public readonly iconPath: vscode.ThemeIcon = new vscode.ThemeIcon('git-branch'); 60 | 61 | /** 62 | * @param baseUri The base URI of the SCM 63 | */ 64 | constructor( 65 | protected readonly vfs: VirtualFileSystem, 66 | public readonly baseUri: vscode.Uri, 67 | ) {} 68 | 69 | /** 70 | * Validate the base URI of the SCM. 71 | * 72 | * @returns A promise that resolves to the validated URI 73 | */ 74 | public static async validateBaseUri(uri: string, projectName?: string): Promise { 75 | return Promise.resolve( vscode.Uri.parse(uri) ); 76 | } 77 | 78 | /** 79 | * Get the input box for the base URI. 80 | * 81 | * @returns The input box 82 | */ 83 | public static get baseUriInputBox(): vscode.QuickPick { 84 | return vscode.window.createQuickPick(); 85 | } 86 | 87 | /** 88 | * Directly write a file to the SCM. 89 | */ 90 | abstract writeFile(path: string, content: Uint8Array): Thenable; 91 | 92 | /** 93 | * Directly read a file from the SCM. 94 | */ 95 | abstract readFile(path: string): Thenable; 96 | 97 | /** 98 | * List history commits *in reverse time order* in the SCM. 99 | * 100 | * @returns An iterable lazy list of commits 101 | */ 102 | abstract list(): Iterable; 103 | 104 | /** 105 | * Apply a commit to the SCM. 106 | * 107 | * @param commitItem The commit to apply 108 | */ 109 | abstract apply(commitItem: CommitItem): Thenable; 110 | 111 | /** 112 | * Sync commits from the other SCM. 113 | * 114 | * @param commits The commits to sync 115 | */ 116 | abstract syncFromSCM(commits: Iterable): Thenable; 117 | 118 | /** 119 | * Define when the SCM should be called. 120 | * 121 | * @returns A list of disposable objects 122 | */ 123 | abstract get triggers(): Promise; 124 | 125 | /** 126 | * Get the configuration items to be shown in QuickPick. 127 | * 128 | * @returns A list of configuration items 129 | */ 130 | abstract get settingItems(): SettingItem[]; 131 | 132 | get status(): StatusInfo { 133 | return this._status; 134 | } 135 | 136 | protected set status(status: StatusInfo) { 137 | this._status = status; 138 | EventBus.fire('scmStatusChangeEvent', {status}); 139 | } 140 | 141 | get scmKey(): string { 142 | return this.baseUri.toString(); 143 | } 144 | 145 | protected getSetting(key: string): T { 146 | return this.settings[key as keyof JSON] as T; 147 | } 148 | 149 | protected setSetting(key: string, value: T) { 150 | const newSettings = {...this.settings, [key]: value}; 151 | this.settings = newSettings; 152 | } 153 | 154 | protected set settings(settings: JSON) { 155 | this.vfs.setProjectSCMPersist(this.scmKey, { 156 | label: (this.constructor as any).label, 157 | baseUri: this.baseUri.toString(), 158 | settings, 159 | }); 160 | } 161 | 162 | protected get settings(): JSON { 163 | return this.vfs.getProjectSCMPersist(this.scmKey).settings; 164 | } 165 | 166 | async diff(): Promise<[number,number]> { 167 | //TODO: 168 | return Promise.resolve([0,0]); 169 | } 170 | } 171 | 172 | -------------------------------------------------------------------------------- /src/scm/localGitBridgeSCM.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { BaseSCM, CommitItem, SettingItem } from "."; 3 | import { VirtualFileSystem } from '../core/remoteFileSystemProvider'; 4 | 5 | export class LocalGitBridgeSCMProvider extends BaseSCM { 6 | public static readonly label = 'Git Bridge'; 7 | 8 | public readonly iconPath: vscode.ThemeIcon = new vscode.ThemeIcon('github'); 9 | 10 | constructor( 11 | vfs: VirtualFileSystem, 12 | public readonly baseUri: vscode.Uri, 13 | ) { 14 | super(vfs, baseUri); 15 | } 16 | 17 | writeFile(path: string, content: Uint8Array): Thenable { 18 | return Promise.resolve(); 19 | } 20 | 21 | readFile(path: string): Thenable { 22 | return Promise.resolve(new Uint8Array()); 23 | } 24 | 25 | list(): Iterable { 26 | return []; 27 | } 28 | 29 | async apply(commitItem: CommitItem): Promise { 30 | return; 31 | } 32 | 33 | async syncFromSCM(commits: Iterable): Promise { 34 | return Promise.resolve(); 35 | } 36 | 37 | get triggers(): Promise { 38 | return Promise.resolve([]); 39 | } 40 | 41 | public static get baseUriInputBox(): vscode.QuickPick { 42 | const inputBox = vscode.window.createQuickPick(); 43 | inputBox.placeholder = 'e.g., https://github.com/username/reponame.git'; 44 | return inputBox; 45 | } 46 | 47 | get settingItems(): SettingItem[] { 48 | return []; 49 | } 50 | } -------------------------------------------------------------------------------- /src/scm/scmCollectionProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { VirtualFileSystem } from '../core/remoteFileSystemProvider'; 3 | 4 | import { BaseSCM, CommitItem, SettingItem } from "."; 5 | import { LocalReplicaSCMProvider } from './localReplicaSCM'; 6 | import { LocalGitBridgeSCMProvider } from './localGitBridgeSCM'; 7 | import { HistoryViewProvider } from './historyViewProvider'; 8 | import { GlobalStateManager } from '../utils/globalStateManager'; 9 | import { EventBus } from '../utils/eventBus'; 10 | import { ROOT_NAME } from '../consts'; 11 | 12 | const supportedSCMs = [ 13 | LocalReplicaSCMProvider, 14 | // LocalGitBridgeSCMProvider, 15 | ]; 16 | type SupportedSCM = typeof supportedSCMs[number]; 17 | 18 | class CoreSCMProvider extends BaseSCM { 19 | constructor(protected readonly vfs: VirtualFileSystem) { 20 | super(vfs, vfs.origin); 21 | } 22 | 23 | validateBaseUri() { return Promise.resolve(true); } 24 | async syncFromSCM() {} 25 | async apply(commitItem: CommitItem) {}; 26 | get triggers() { return Promise.resolve([]); } 27 | get settingItems() { return[]; } 28 | 29 | writeFile(path: string, content: Uint8Array): Thenable { 30 | const uri = this.vfs.pathToUri(path); 31 | return vscode.workspace.fs.writeFile(uri, content); 32 | } 33 | 34 | readFile(path: string): Thenable { 35 | const uri = this.vfs.pathToUri(path); 36 | return vscode.workspace.fs.readFile(uri); 37 | } 38 | 39 | list(): Iterable { 40 | return []; 41 | } 42 | } 43 | 44 | interface SCMRecord { 45 | scm: BaseSCM; 46 | enabled: boolean; 47 | triggers: vscode.Disposable[]; 48 | } 49 | 50 | export class SCMCollectionProvider extends vscode.Disposable { 51 | private readonly core: CoreSCMProvider; 52 | private readonly scms: SCMRecord[] = []; 53 | private readonly statusBarItem: vscode.StatusBarItem; 54 | private readonly statusListener: vscode.Disposable; 55 | private historyDataProvider: HistoryViewProvider; 56 | 57 | constructor( 58 | private readonly vfs: VirtualFileSystem, 59 | private readonly context: vscode.ExtensionContext, 60 | ) { 61 | // define the dispose behavior 62 | super(() => { 63 | this.scms.forEach(scm => scm.triggers.forEach(t => t.dispose())); 64 | }); 65 | 66 | this.core = new CoreSCMProvider( vfs ); 67 | this.historyDataProvider = new HistoryViewProvider( vfs ); 68 | this.initSCMs(); 69 | 70 | this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); 71 | this.statusBarItem.command = `${ROOT_NAME}.projectSCM.configSCM`; 72 | this.statusListener = EventBus.on('scmStatusChangeEvent', () => {this.updateStatus();}); 73 | } 74 | 75 | private updateStatus() { 76 | if (!this.statusBarItem) { return; } 77 | 78 | let numPush = 0, numPull = 0; 79 | let tooltip = new vscode.MarkdownString(`**${vscode.l10n.t('Project Source Control')}**\n\n`); 80 | tooltip.supportHtml = true; 81 | tooltip.supportThemeIcons = true; 82 | 83 | // update status bar item tooltip 84 | if (this.scms.length===0) { 85 | tooltip.appendMarkdown(`*${vscode.l10n.t('Click to configure.')}*\n\n`); 86 | } else { 87 | for (const {scm,enabled} of this.scms) { 88 | const icon = scm.iconPath.id; 89 | const label = (scm.constructor as any).label; 90 | const uri = scm.baseUri.toString(); 91 | const slideUri = uri.length<=30? uri : uri.replace(/^(.{15}).*(.{15})$/, '$1...$2'); 92 | tooltip.appendMarkdown(`----\n\n$(${icon}) **${label}**: [${slideUri}](${uri})\n\n`); 93 | // 94 | if (!enabled) { 95 | tooltip.appendMarkdown(`     *${vscode.l10n.t('Disabled')}.*\n\n`); 96 | } else if (scm.status.status==='idle') { 97 | tooltip.appendMarkdown(`     *${vscode.l10n.t('Synced')}.*\n\n`); 98 | } else { 99 | // show status message 100 | tooltip.appendMarkdown(`     ***${scm.status.message}***\n\n`); 101 | // update counters 102 | switch (scm.status.status) { 103 | case 'push': numPush++; break; 104 | case 'pull': numPull++; break; 105 | } 106 | } 107 | } 108 | } 109 | this.statusBarItem.tooltip = tooltip; 110 | 111 | // update status bar item text 112 | if (numPush!==0) { 113 | this.statusBarItem.text = `$(cloud-upload)`; 114 | } else if (numPull!==0) { 115 | this.statusBarItem.text = `$(cloud-download)`; 116 | } else { 117 | this.statusBarItem.text = `$(cloud)`; 118 | } 119 | 120 | this.statusBarItem.show(); 121 | } 122 | 123 | private initSCMs() { 124 | const scmPersists = GlobalStateManager.getServerProjectSCMPersists(this.context, this.vfs.serverName, this.vfs.projectId); 125 | Object.values(scmPersists).forEach(async scmPersist => { 126 | const scmProto = supportedSCMs.find(scm => scm.label===scmPersist.label); 127 | if (scmProto!==undefined) { 128 | const enabled = scmPersist.enabled ?? true; 129 | const baseUri = vscode.Uri.parse(scmPersist.baseUri); 130 | await this.createSCM(scmProto, baseUri, false, enabled); 131 | } 132 | }); 133 | } 134 | 135 | private async createSCM(scmProto: SupportedSCM, baseUri: vscode.Uri, newSCM=false, enabled=true) { 136 | const scm = new scmProto(this.vfs, baseUri); 137 | // insert into global state 138 | if (newSCM) { 139 | this.vfs.setProjectSCMPersist(scm.scmKey, { 140 | enabled: enabled, 141 | label: scmProto.label, 142 | baseUri: scm.baseUri.path, 143 | settings: {} as JSON, 144 | }); 145 | } 146 | // insert into collection 147 | try { 148 | const triggers = enabled ? await scm.triggers : []; 149 | this.scms.push({scm,enabled,triggers}); 150 | this.updateStatus(); 151 | return scm; 152 | } catch (error) { 153 | // permanently remove failed scm 154 | // this.vfs.setProjectSCMPersist(scm.scmKey, undefined); 155 | vscode.window.showErrorMessage( vscode.l10n.t('"{scm}" creation failed.', {scm:scmProto.label}) ); 156 | return undefined; 157 | } 158 | } 159 | 160 | private removeSCM(item: SCMRecord) { 161 | const index = this.scms.indexOf(item); 162 | if (index!==-1) { 163 | // remove from collection 164 | item.triggers.forEach(trigger => trigger.dispose()); 165 | this.scms.splice(index, 1); 166 | // remove from global state 167 | this.vfs.setProjectSCMPersist(item.scm.scmKey, undefined); 168 | this.updateStatus(); 169 | } 170 | } 171 | 172 | private createNewSCM(scmProto: SupportedSCM) { 173 | return new Promise(resolve => { 174 | const inputBox = scmProto.baseUriInputBox; 175 | inputBox.ignoreFocusOut = true; 176 | inputBox.title = vscode.l10n.t('Create Source Control: {scm}', {scm:scmProto.label}); 177 | inputBox.buttons = [{iconPath: new vscode.ThemeIcon('check')}]; 178 | inputBox.show(); 179 | // 180 | inputBox.onDidTriggerButton(() => { 181 | inputBox.hide(); 182 | resolve(inputBox.value); 183 | }); 184 | inputBox.onDidAccept(() => { 185 | if (inputBox.activeItems.length===0) { 186 | inputBox.hide(); 187 | resolve(inputBox.value); 188 | } 189 | }); 190 | }) 191 | .then((uri) => scmProto.validateBaseUri(uri as string || '', this.vfs.projectName)) 192 | .then(async (baseUri) => { 193 | if (baseUri) { 194 | const scm = await this.createSCM(scmProto, baseUri, true); 195 | if (scm) { 196 | vscode.window.showInformationMessage( vscode.l10n.t('"{scm}" created: {uri}.', {scm:scmProto.label, uri: decodeURI(scm.baseUri.toString()) }) ); 197 | } else { 198 | vscode.window.showErrorMessage( vscode.l10n.t('"{scm}" creation failed.', {scm:scmProto.label}) ); 199 | } 200 | } 201 | }); 202 | } 203 | 204 | private configSCM(scmItem: SCMRecord) { 205 | const baseUri = scmItem.scm.baseUri.toString(); 206 | const settingItems = scmItem.scm.settingItems as SettingItem[]; 207 | const status = scmItem.enabled? scmItem.scm.status.status : 'disabled'; 208 | const quickPickItems = [ 209 | {label:scmItem.enabled?'Disable':'Enable', description:`Status: ${status}`}, 210 | {label:'Remove', description:`${baseUri}`}, 211 | {label:'', kind:vscode.QuickPickItemKind.Separator}, 212 | ...settingItems, 213 | ]; 214 | 215 | return vscode.window.showQuickPick(quickPickItems, { 216 | ignoreFocusOut: true, 217 | title: vscode.l10n.t('Project Source Control Management'), 218 | }).then(async (select) => { 219 | if (select===undefined) { return; } 220 | switch (select.label) { 221 | case 'Enable': 222 | case 'Disable': 223 | const persist = this.vfs.getProjectSCMPersist(scmItem.scm.scmKey); 224 | persist.enabled = !(persist.enabled ?? true); 225 | this.vfs.setProjectSCMPersist(scmItem.scm.scmKey, persist); 226 | // 227 | const scmIndex = this.scms.indexOf(scmItem); 228 | this.scms[scmIndex].enabled = persist.enabled; 229 | if (persist.enabled) { 230 | scmItem.triggers = await scmItem.scm.triggers; 231 | } else { 232 | scmItem.triggers.forEach(trigger => trigger.dispose()); 233 | scmItem.triggers = []; 234 | } 235 | this.updateStatus(); 236 | vscode.window.showWarningMessage(`"${(scmItem.scm.constructor as any).label}" ${persist.enabled?'enabled':'disabled'}: ${baseUri}.`); 237 | break; 238 | case 'Remove': 239 | vscode.window.showWarningMessage(`${vscode.l10n.t('Remove')} ${baseUri}?`, 'Yes', 'No') 240 | .then((select) => { 241 | if (select==='Yes') { 242 | this.removeSCM(scmItem); 243 | } 244 | }); 245 | break; 246 | default: 247 | const settingItem = settingItems.find(item => item.label===select.label); 248 | settingItem?.callback(); 249 | break; 250 | } 251 | }); 252 | } 253 | 254 | showSCMConfiguration() { 255 | // group 1: show existing scms 256 | const scmItems: vscode.QuickPickItem[] = this.scms.map((item) => { 257 | const { scm } = item; 258 | return { 259 | label: (scm.constructor as any).label, 260 | iconPath: scm.iconPath, 261 | description: scm.baseUri.toString(), 262 | item, 263 | }; 264 | }); 265 | if (scmItems.length!==0) { 266 | scmItems.push({kind:vscode.QuickPickItemKind.Separator, label:''}); 267 | } 268 | // group 2: create new scm 269 | const createItems: vscode.QuickPickItem[] = supportedSCMs.map((scmProto) => { 270 | return { 271 | label: vscode.l10n.t('Create Source Control: {scm}', {scm:scmProto.label}), 272 | scmProto, 273 | }; 274 | }); 275 | 276 | // show quick pick 277 | vscode.window.showQuickPick([...scmItems, ...createItems], { 278 | ignoreFocusOut: true, 279 | title: vscode.l10n.t('Project Source Control Management'), 280 | }).then((select) => { 281 | if (select) { 282 | const _select = select as any; 283 | // configure existing scm 284 | if (_select.item) { 285 | this.configSCM( _select.item as SCMRecord ); 286 | } 287 | // create new scm 288 | if ( _select.scmProto ) { 289 | this.createNewSCM(_select.scmProto as SupportedSCM ); 290 | } 291 | } 292 | }); 293 | } 294 | 295 | get triggers() { 296 | return [ 297 | // Register: HistoryViewProvider 298 | ...this.historyDataProvider.triggers, 299 | // register status bar item 300 | this.statusBarItem, 301 | this.statusListener, 302 | // register commands 303 | vscode.commands.registerCommand(`${ROOT_NAME}.projectSCM.configSCM`, () => { 304 | return this.showSCMConfiguration(); 305 | }), 306 | vscode.commands.registerCommand(`${ROOT_NAME}.projectSCM.newSCM`, (scmProto) => { 307 | return this.createNewSCM(scmProto); 308 | }), 309 | this as vscode.Disposable, 310 | ]; 311 | } 312 | 313 | } -------------------------------------------------------------------------------- /src/utils/eventBus.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import {EventEmitter} from 'events'; 3 | import { PdfDocument } from '../core/pdfViewEditorProvider'; 4 | import { StatusInfo } from '../scm'; 5 | 6 | export type Events = { 7 | 'fileWillOpenEvent': {uri: vscode.Uri}, 8 | 'pdfWillOpenEvent': {uri: vscode.Uri, doc:PdfDocument, webviewPanel:vscode.WebviewPanel}, 9 | 'spellCheckLanguageUpdateEvent': {language:string}, 10 | 'compilerUpdateEvent': {compiler:string}, 11 | 'rootDocUpdateEvent': {rootDocId:string}, 12 | 'scmStatusChangeEvent': {status:StatusInfo}, 13 | 'socketioConnectedEvent': {publicId:string}, 14 | }; 15 | 16 | export class EventBus { 17 | private static _eventEmitter = new EventEmitter(); 18 | 19 | static fire(eventName: T, arg: Events[T]): void { 20 | EventBus._eventEmitter.emit(eventName, arg); 21 | } 22 | 23 | static on(eventName: T, cb: (arg: Events[T]) => void): vscode.Disposable { 24 | EventBus._eventEmitter.on(eventName, cb); 25 | const disposable = { 26 | dispose: () => { EventBus._eventEmitter.removeListener(eventName, cb); } 27 | }; 28 | return disposable; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/globalStateManager.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Identity, BaseAPI, ProjectPersist } from '../api/base'; 3 | import { SocketIOAPI } from '../api/socketio'; 4 | import { ExtendedBaseAPI } from '../api/extendedBase'; 5 | 6 | const keyServerPersists: string = 'overleaf-servers'; 7 | const keyPdfViewPersists: string = 'overleaf-pdf-viewers'; 8 | 9 | export interface ServerPersist { 10 | name: string; 11 | url: string; 12 | login?: { 13 | userId: string; 14 | username: string; 15 | identity: Identity; 16 | projects?: ProjectPersist[] 17 | }; 18 | } 19 | type ServerPersistMap = {[name: string]: ServerPersist}; 20 | 21 | export interface ProjectSCMPersist { 22 | enabled: boolean; 23 | label: string; 24 | baseUri: string; 25 | settings: JSON; 26 | } 27 | type ProjectSCMPersistMap = {[name: string]: ProjectSCMPersist}; 28 | 29 | type PdfViewPersist = { 30 | frequency: number, 31 | state: any, 32 | }; 33 | type PdfViewPersistMap = {[uri: string]: PdfViewPersist}; 34 | 35 | export class GlobalStateManager { 36 | 37 | static getServers(context:vscode.ExtensionContext): {server:ServerPersist, api:BaseAPI}[] { 38 | const persists = context.globalState.get(keyServerPersists, {}); 39 | const servers = Object.values(persists).map(persist => { 40 | return { 41 | server: persist, 42 | api: new BaseAPI(persist.url), 43 | }; 44 | }); 45 | 46 | if (servers.length===0) { 47 | const url = new URL('https://www.overleaf.com'); 48 | this.addServer(context, url.host, url.href); 49 | return this.getServers(context); 50 | } else { 51 | return servers; 52 | } 53 | } 54 | 55 | static addServer(context:vscode.ExtensionContext, name:string, url:string): boolean { 56 | const persists = context.globalState.get(keyServerPersists, {}); 57 | if ( persists[name]===undefined ) { 58 | persists[name] = { name, url }; 59 | context.globalState.update(keyServerPersists, persists); 60 | return true; 61 | } else { 62 | return false; 63 | } 64 | } 65 | 66 | static removeServer(context:vscode.ExtensionContext, name:string): boolean { 67 | const persists = context.globalState.get(keyServerPersists, {}); 68 | if ( persists[name]!==undefined ) { 69 | delete persists[name]; 70 | context.globalState.update(keyServerPersists, persists); 71 | return true; 72 | } else { 73 | return false; 74 | } 75 | } 76 | 77 | static async loginServer(context:vscode.ExtensionContext, api:BaseAPI, name:string, auth:{[key:string]:string}): Promise { 78 | const persists = context.globalState.get(keyServerPersists, {}); 79 | const server = persists[name]; 80 | 81 | if (server.login===undefined) { 82 | const res = auth.cookies ? await api.cookiesLogin(auth.cookies) : await api.passportLogin(auth.email, auth.password); 83 | if (res.type==='success' && res.identity!==undefined && res.userInfo!==undefined) { 84 | server.login = { 85 | userId: res.userInfo.userId, 86 | username: auth.email || res.userInfo.userEmail, 87 | identity: res.identity 88 | }; 89 | context.globalState.update(keyServerPersists, persists); 90 | return true; 91 | } else { 92 | if (res.message!==undefined) { 93 | vscode.window.showErrorMessage(res.message); 94 | } 95 | return false; 96 | } 97 | } else { 98 | return false; 99 | } 100 | } 101 | 102 | static async logoutServer(context:vscode.ExtensionContext, api:BaseAPI, name:string): Promise { 103 | const persists = context.globalState.get(keyServerPersists, {}); 104 | const server = persists[name]; 105 | 106 | if (server.login!==undefined) { 107 | await api.logout(server.login.identity); 108 | delete server.login; 109 | context.globalState.update(keyServerPersists, persists); 110 | return true; 111 | } else { 112 | return false; 113 | } 114 | } 115 | 116 | static async fetchServerProjects(context:vscode.ExtensionContext, api:BaseAPI, name:string): Promise { 117 | const persists = context.globalState.get(keyServerPersists, {}); 118 | const server = persists[name]; 119 | 120 | if (server.login!==undefined) { 121 | let res = await api.getProjectsJson(server.login.identity); 122 | if (res.type!=='success') { 123 | // fallback to `userProjectsJson` 124 | res = await api.userProjectsJson(server.login.identity); 125 | } 126 | if (res.type==='success' && res.projects!==undefined) { 127 | Object.values(res.projects).forEach(project => { 128 | project.userId = (server.login as any).userId; 129 | }); 130 | const projects = res.projects.map(project => { 131 | const existProject = server.login?.projects?.find(p => p.id===project.id); 132 | // merge existing scm 133 | if (existProject) { 134 | project.scm = existProject.scm; 135 | } 136 | return project; 137 | }); 138 | server.login.projects = projects; 139 | context.globalState.update(keyServerPersists, persists); 140 | return projects; 141 | } else { 142 | // regex match for cookie expired 143 | const cookieExpireRegex = /^302/; 144 | if (res.message && cookieExpireRegex.test(res.message)) { 145 | vscode.window.showErrorMessage(vscode.l10n.t('Cookie Expired. Please Re-Login')); 146 | return Promise.reject(); 147 | } 148 | if (res.message!==undefined) { 149 | vscode.window.showErrorMessage(res.message); 150 | } 151 | return []; 152 | } 153 | } else { 154 | return []; 155 | } 156 | } 157 | 158 | static authenticate(context:vscode.ExtensionContext, name:string) { 159 | const persists = context.globalState.get(keyServerPersists, {}); 160 | const server = persists[name]; 161 | return server.login!==undefined ? 162 | Promise.resolve(server.login.identity): 163 | Promise.reject(); 164 | } 165 | 166 | static initSocketIOAPI(context:vscode.ExtensionContext, name:string, projectId:string) { 167 | const persists = context.globalState.get(keyServerPersists, {}); 168 | const server = persists[name]; 169 | 170 | if (server.login!==undefined) { 171 | const api = new ExtendedBaseAPI(server.url); 172 | const socket = new SocketIOAPI(server.url, api, server.login.identity, projectId); 173 | return {api, socket}; 174 | } 175 | } 176 | 177 | static getServerProjectSCMPersists(context:vscode.ExtensionContext, serverName:string, projectId:string) { 178 | const persists = context.globalState.get(keyServerPersists, {}); 179 | const server = persists[serverName]; 180 | const project = server.login?.projects?.find(project => project.id===projectId); 181 | const scmPersists = project?.scm ? project.scm as ProjectSCMPersistMap : {}; 182 | return scmPersists; 183 | } 184 | 185 | static updateServerProjectSCMPersist(context:vscode.ExtensionContext, serverName:string, projectId:string, scmKey:string, scmPersist?:ProjectSCMPersist) { 186 | const persists = context.globalState.get(keyServerPersists, {}); 187 | const server = persists[serverName]; 188 | const project = server.login?.projects?.find(project => project.id===projectId); 189 | if (project) { 190 | const scmPersists = (project.scm ?? {}) as ProjectSCMPersistMap; 191 | if (scmPersist===undefined) { 192 | delete scmPersists[scmKey]; 193 | } else { 194 | scmPersists[scmKey] = scmPersist; 195 | } 196 | project.scm = scmPersists; 197 | context.globalState.update(keyServerPersists, persists); 198 | } 199 | } 200 | 201 | static getPdfViewPersist(context:vscode.ExtensionContext, uri:string): any { 202 | return context.globalState.get(keyPdfViewPersists, {})[uri]?.state; 203 | } 204 | 205 | static updatePdfViewPersist(context:vscode.ExtensionContext, uri:string, state:any) { 206 | const persists = context.globalState.get(keyPdfViewPersists, {}); 207 | 208 | // update record 209 | if (persists[uri]!==undefined) { 210 | persists[uri].frequency++; 211 | persists[uri].state = state; 212 | } else { 213 | persists[uri] = {frequency: 1, state}; 214 | } 215 | 216 | // when length>=100, remove first least used record 217 | if (Object.keys(persists).length>=100) { 218 | let minFrequency = Number.MAX_SAFE_INTEGER; 219 | let minUri = ''; 220 | Object.entries(persists).forEach(([uri, persist]) => { 221 | if (persist.frequency 2 | -------------------------------------------------------------------------------- /views/chat-view/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /views/chat-view/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-view", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "run-p type-check \"build-only {@}\" --", 8 | "preview": "vite preview", 9 | "build-only": "vite build", 10 | "watch": "vite build --watch", 11 | "type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false" 12 | }, 13 | "dependencies": { 14 | "@vscode/codicons": "^0.0.33", 15 | "@vscode/webview-ui-toolkit": "^1.2.2", 16 | "@vueuse/core": "^10.5.0", 17 | "markdown-it": "^13.0.2", 18 | "vue": "^3.3.4" 19 | }, 20 | "devDependencies": { 21 | "@tsconfig/node18": "^18.2.2", 22 | "@types/markdown-it": "^13.0.4", 23 | "@types/node": "^18.17.17", 24 | "@types/vscode-webview": "^1.57.2", 25 | "@vitejs/plugin-vue": "^5.2.1", 26 | "@vue/tsconfig": "^0.4.0", 27 | "npm-run-all2": "^6.0.6", 28 | "typescript": "~5.2.0", 29 | "vite": "^6.2.6", 30 | "vite-plugin-singlefile": "^0.13.5", 31 | "vue-tsc": "^2.0.29" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /views/chat-view/src/App.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 59 | 60 | 61 | 74 | -------------------------------------------------------------------------------- /views/chat-view/src/components/InputBox.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 86 | 87 | 93 | -------------------------------------------------------------------------------- /views/chat-view/src/components/MessageItem.vue: -------------------------------------------------------------------------------- 1 | 96 | 97 | 133 | 134 | -------------------------------------------------------------------------------- /views/chat-view/src/components/MessageList.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 73 | 74 | -------------------------------------------------------------------------------- /views/chat-view/src/components/NewMessageNotice.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | 19 | -------------------------------------------------------------------------------- /views/chat-view/src/components/QuickReply.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 23 | 24 | -------------------------------------------------------------------------------- /views/chat-view/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App.vue'; 3 | 4 | createApp(App).mount('#app'); 5 | -------------------------------------------------------------------------------- /views/chat-view/src/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { vscode } from "./vscode"; 3 | import { type Ref } from "vue"; 4 | 5 | export interface Message { 6 | id: string, 7 | content: string, 8 | timestamp: number, 9 | user_id: string, 10 | user: { 11 | id: string, 12 | first_name: string, 13 | last_name?: string, 14 | email: string, 15 | }, 16 | clientId: string, 17 | replyTo?: {messageId:string, username:string, userId:string}, 18 | replies?: Message[], 19 | newMessage?: boolean, 20 | } 21 | 22 | export function elapsedTime(timestamp:number, now=Date.now()) { 23 | const msPerMinute = 60 * 1000; 24 | const msPerHour = msPerMinute * 60; 25 | const msPerDay = msPerHour * 24; 26 | const msPerMonth = msPerDay * 30; 27 | const msPerYear = msPerDay * 365; 28 | 29 | const elapsed = Math.max(now - timestamp, 0); 30 | 31 | if (elapsed < msPerMinute) { 32 | const elapsedSeconds = Math.round(elapsed/1000); 33 | return elapsedSeconds===0 ? 'now' : elapsedSeconds + 's'; 34 | } else if (elapsed < msPerHour) { 35 | const elapsedMinutes = Math.round(elapsed/msPerMinute); 36 | return elapsedMinutes===1 ? '1 min' : elapsedMinutes + ' mins'; 37 | } else if (elapsed < msPerDay ) { 38 | const elapsedHours = Math.round(elapsed/msPerHour ); 39 | return elapsedHours===1 ? '1 hour' : elapsedHours + ' hours'; 40 | } else if (elapsed < msPerMonth) { 41 | const elapsedDays = Math.round(elapsed/msPerDay); 42 | return elapsedDays===1 ? '1 day' : elapsedDays + ' days'; 43 | } else if (elapsed < msPerYear) { 44 | const elapsedMonths = Math.round(elapsed/msPerMonth); 45 | return elapsedMonths===1 ? '1 month' : elapsedMonths + ' months'; 46 | } else { 47 | const elapsedYears = Math.round(elapsed/msPerYear ); 48 | return elapsedYears===1 ? '1 year' : elapsedYears + ' years'; 49 | } 50 | } 51 | 52 | export function getMessages() { 53 | vscode.postMessage({ 54 | type: 'get-messages', 55 | }); 56 | } 57 | 58 | export function sendMessage(content: string, context?:string) { 59 | content = content.trim(); 60 | if (context) { 61 | content = `${context}\n\n${content}`; 62 | } 63 | 64 | vscode.postMessage({ 65 | type: 'send-message', 66 | content 67 | }); 68 | } 69 | 70 | export function showLineRef(path:string, L1:number, C1:number, L2:number, C2:number) { 71 | vscode.postMessage({ 72 | type: 'show-line-ref', 73 | content: {path, L1, C1, L2, C2}, 74 | }); 75 | } 76 | 77 | export function getReplyContext(message: Message) { 78 | // const slidingTextLength = 20; 79 | const username = `${message.user.first_name} ${message.user.last_name||''}`; 80 | // let slidingText = message.content.split('\n').join(' ').slice(0, slidingTextLength); 81 | // slidingText += message.content.length>=slidingTextLength ? '...' : ''; 82 | 83 | return [ 84 | `> reply-to-${message.id} [@${username}](${message.user.id})`, 85 | // `> ${slidingText}` 86 | ].join('\n'); 87 | } 88 | 89 | export class MessageTree { 90 | private replyRegex = /^>\s*reply-to-(\w+)\s*\[@(.+)\]\((\w+)\)\s*$/; 91 | private rootMap: Record = {}; 92 | userId: string = ''; 93 | 94 | constructor( 95 | private messages: Ref, 96 | private unreadRecord: Ref, 97 | ) {} 98 | 99 | update(messages: Message[]) { 100 | messages.forEach(message => { 101 | this.pushMessage(message); 102 | }); 103 | } 104 | 105 | pushMessage(message: Message, newMessage:boolean=false) { 106 | // update unread record 107 | newMessage = newMessage && (message.user.id !== this.userId); 108 | message.newMessage = newMessage; 109 | newMessage && this.unreadRecord.value.push(message.id); 110 | 111 | const _lines = message.content.trim().split('\n'); 112 | const _firstLine = _lines[0]; 113 | const match = _firstLine.match(this.replyRegex); 114 | 115 | if (match) { 116 | // update message 117 | const [_, messageId, username, userId] = match; 118 | message.content = _lines.slice(1).join('\n'); 119 | message.replyTo = {messageId, username, userId}; 120 | // insert into root's replies 121 | const rootId = this.rootMap[messageId]; 122 | const rootMessage = this.messages.value.find(m => m.id===rootId); 123 | rootMessage?.replies && rootMessage.replies.push(message); 124 | // update rootMap 125 | this.rootMap[message.id] = rootId; 126 | } else { 127 | // update message 128 | message.replies = []; 129 | // insert as root 130 | this.messages.value.push(message); 131 | // update rootMap 132 | this.rootMap[message.id] = message.id; 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /views/chat-view/src/vscode.ts: -------------------------------------------------------------------------------- 1 | // Forked from: https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-vue/webview-ui/src/utilities/vscode.ts 2 | 3 | import type { WebviewApi } from "vscode-webview"; 4 | 5 | /** 6 | * A utility wrapper around the acquireVsCodeApi() function, which enables 7 | * message passing and state management between the webview and extension 8 | * contexts. 9 | * 10 | * This utility also enables webview code to be run in a web browser-based 11 | * dev server by using native web browser features that mock the functionality 12 | * enabled by acquireVsCodeApi. 13 | */ 14 | class VSCodeAPIWrapper { 15 | private readonly vsCodeApi: WebviewApi | undefined; 16 | 17 | constructor() { 18 | // Check if the acquireVsCodeApi function exists in the current development 19 | // context (i.e. VS Code development window or web browser) 20 | if (typeof acquireVsCodeApi === "function") { 21 | this.vsCodeApi = acquireVsCodeApi(); 22 | } 23 | } 24 | 25 | /** 26 | * Post a message (i.e. send arbitrary data) to the owner of the webview. 27 | * 28 | * @remarks When running webview code inside a web browser, postMessage will instead 29 | * log the given message to the console. 30 | * 31 | * @param message Abitrary data (must be JSON serializable) to send to the extension context. 32 | */ 33 | public postMessage(message: unknown) { 34 | if (this.vsCodeApi) { 35 | this.vsCodeApi.postMessage(message); 36 | } else { 37 | console.log(message); 38 | } 39 | } 40 | 41 | /** 42 | * Get the persistent state stored for this webview. 43 | * 44 | * @remarks When running webview source code inside a web browser, getState will retrieve state 45 | * from local storage (https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). 46 | * 47 | * @return The current state or `undefined` if no state has been set. 48 | */ 49 | public getState(): unknown | undefined { 50 | if (this.vsCodeApi) { 51 | return this.vsCodeApi.getState(); 52 | } else { 53 | const state = localStorage.getItem("vscodeState"); 54 | return state ? JSON.parse(state) : undefined; 55 | } 56 | } 57 | 58 | /** 59 | * Set the persistent state stored for this webview. 60 | * 61 | * @remarks When running webview source code inside a web browser, setState will set the given 62 | * state using local storage (https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). 63 | * 64 | * @param newState New persisted state. This must be a JSON serializable object. Can be retrieved 65 | * using {@link getState}. 66 | * 67 | * @return The new state. 68 | */ 69 | public setState(newState: T): T { 70 | if (this.vsCodeApi) { 71 | return this.vsCodeApi.setState(newState); 72 | } else { 73 | localStorage.setItem("vscodeState", JSON.stringify(newState)); 74 | return newState; 75 | } 76 | } 77 | } 78 | 79 | // Exports class singleton to prevent multiple invocations of acquireVsCodeApi. 80 | export const vscode = new VSCodeAPIWrapper(); -------------------------------------------------------------------------------- /views/chat-view/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "@/*": ["./src/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /views/chat-view/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /views/chat-view/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "include": [ 4 | "vite.config.*" 5 | ], 6 | "compilerOptions": { 7 | "composite": true, 8 | "module": "ESNext", 9 | "moduleResolution": "Bundler", 10 | "types": ["node"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /views/chat-view/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import { viteSingleFile } from "vite-plugin-singlefile" 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | vue(), 11 | viteSingleFile(), 12 | ], 13 | resolve: { 14 | alias: { 15 | '@': fileURLToPath(new URL('./src', import.meta.url)) 16 | } 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /views/pdf-viewer/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | 8 | #pdf-viewer { 9 | display: block; 10 | border: none; 11 | overflow: hidden; 12 | position: absolute; 13 | width: 100%; 14 | height: 100%; 15 | } 16 | 17 | #openFile, #print, #download { 18 | display: none; 19 | } 20 | 21 | #secondaryOpenFile, 22 | #secondaryPrint, 23 | #secondaryDownload { 24 | display: none; 25 | } 26 | -------------------------------------------------------------------------------- /views/pdf-viewer/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | "use strict"; 3 | 4 | // Reference: https://github.com/tomoki1207/vscode-pdfviewer/blob/main/lib/main.js 5 | (function(){ 6 | const CursorTool = { SELECT:0, HAND:1, ZOOM:2 }; 7 | const SpreadMode = { UNKNOWN:-1, NONE:0, ODD:1, EVEN:2 }; 8 | const ScrollMode = { UNKNOWN:-1, VERTICAL:0, HORIZONTAL:1, WRAPPED:2, PAGE:3 }; 9 | const SidebarView = { UNKNOWN:-1, NONE:0, THUMBS:1, OUTLINE:2, ATTACHMENTS:3, LAYERS:4 }; 10 | let ColorThemes = { 11 | 'default': {fontColor:'black', bgColor:'white'}, 12 | 'light': {fontColor:'black', bgColor:'#F5F5DC'}, 13 | 'dark': {fontColor:'#FBF0D9', bgColor:'#4B4B4B'} 14 | }; 15 | 16 | // @ts-ignore 17 | const vscode = acquireVsCodeApi(); 18 | let globalPdfViewerState = { 19 | colorTheme: 'default', 20 | containerScrollLeft: 0, 21 | containerScrollTop: 0, 22 | currentScaleValue: 'auto', 23 | pdfCursorTools: CursorTool.SELECT, 24 | pdfViewerScrollMode: ScrollMode.VERTICAL, 25 | pdfViewerSpreadMode: SpreadMode.NONE, 26 | pdfSidebarView: SidebarView.NONE, 27 | }; 28 | let firstLoaded = true; 29 | 30 | function updatePdfViewerState() { 31 | const pdfViewerState = vscode.getState() || globalPdfViewerState; 32 | 33 | if (ColorThemes[pdfViewerState.colorTheme] === undefined) { 34 | pdfViewerState.colorTheme = Object.keys(ColorThemes)[0]; 35 | } 36 | pdfjsLib.ViewerFontColor = ColorThemes[pdfViewerState.colorTheme].fontColor; 37 | pdfjsLib.ViewerBgColor = ColorThemes[pdfViewerState.colorTheme].bgColor; 38 | 39 | PDFViewerApplication.pdfViewer.currentScaleValue = pdfViewerState.currentScaleValue; 40 | PDFViewerApplication.pdfCursorTools.switchTool( pdfViewerState.pdfCursorTools ); 41 | PDFViewerApplication.pdfViewer.scrollMode = pdfViewerState.pdfViewerScrollMode; 42 | PDFViewerApplication.pdfViewer.spreadMode = pdfViewerState.pdfViewerSpreadMode; 43 | PDFViewerApplication.pdfSidebar.setInitialView( pdfViewerState.pdfSidebarView ); 44 | PDFViewerApplication.pdfSidebar.switchView( pdfViewerState.pdfSidebarView ); 45 | document.getElementById('viewerContainer').scrollLeft = pdfViewerState.containerScrollLeft; 46 | document.getElementById('viewerContainer').scrollTop = pdfViewerState.containerScrollTop; 47 | PDFViewerApplication.pdfViewer.refresh(); 48 | } 49 | 50 | function backupPdfViewerState() { 51 | if (PDFViewerApplication.pdfViewer.currentScaleValue !== null) { 52 | console.log( PDFViewerApplication.pdfViewer.currentScaleValue ); 53 | globalPdfViewerState.currentScaleValue = PDFViewerApplication.pdfViewer.currentScaleValue; 54 | } 55 | globalPdfViewerState.pdfViewerScrollMode = PDFViewerApplication.pdfViewer.scrollMode; 56 | globalPdfViewerState.pdfViewerSpreadMode = PDFViewerApplication.pdfViewer.spreadMode; 57 | globalPdfViewerState.pdfSidebarView = PDFViewerApplication.pdfSidebar.visibleView; 58 | globalPdfViewerState.containerScrollLeft = document.getElementById('viewerContainer').scrollLeft || 0; 59 | globalPdfViewerState.containerScrollTop = document.getElementById('viewerContainer').scrollTop || 0; 60 | vscode.setState(globalPdfViewerState); 61 | vscode.postMessage({ 62 | type: 'saveState', 63 | content: globalPdfViewerState, 64 | }); 65 | } 66 | 67 | function updateColorThemes(themes) { 68 | ColorThemes = themes; 69 | // set global css 70 | const style = document.createElement('style'); 71 | for (const theme in ColorThemes) { 72 | // sanitize theme name 73 | if (theme.match(/^[a-zA-Z0-9-_]+$/) === null) { 74 | continue; 75 | } 76 | // sanitize color value 77 | if (ColorThemes[theme].fontColor.match(/^#[0-9a-fA-F]{6}$/) === null) { 78 | continue; 79 | } 80 | if (ColorThemes[theme].bgColor.match(/^#[0-9a-fA-F]{6}$/) === null) { 81 | continue; 82 | } 83 | // update css 84 | style.innerHTML += ` 85 | #theme-${theme}::before { 86 | background-color: ${ColorThemes[theme].bgColor}; 87 | } 88 | `; 89 | } 90 | document.head.appendChild(style); 91 | } 92 | 93 | function enableThemeToggleButton(initIndex = 0){ 94 | // create toggle theme button 95 | const button = document.createElement('button'); 96 | button.setAttribute('class', 'toolbarButton hiddenMediumView'); 97 | button.setAttribute('theme-index', initIndex); 98 | button.setAttribute('tabindex', '30'); 99 | // set button theme attribute 100 | const setAttribute = (index) => { 101 | const theme = Object.keys(ColorThemes)[index]; 102 | globalPdfViewerState.colorTheme = theme; 103 | button.innerHTML = `${theme}`; 104 | button.setAttribute('title', `Theme: ${theme}`); 105 | button.setAttribute('id', `theme-${theme}`); 106 | }; 107 | button.addEventListener('click', () => { 108 | const index = Number(button.getAttribute('theme-index')); 109 | const next = (index + 1) % Object.keys(ColorThemes).length; 110 | button.setAttribute('theme-index', next); 111 | setAttribute(next); 112 | backupPdfViewerState(); 113 | updatePdfViewerState(); 114 | }); 115 | setAttribute(initIndex); 116 | // 117 | const container = document.getElementById('toolbarViewerRight'); 118 | const firstChild = document.getElementById('openFile'); 119 | container.insertBefore(button, firstChild); 120 | } 121 | 122 | async function updatePdf(pdf) { 123 | const doc = await pdfjsLib.getDocument({ 124 | data: pdf, 125 | cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.10.111/cmaps/', 126 | cMapPacked: true 127 | }).promise; 128 | if (firstLoaded) { 129 | firstLoaded = false; 130 | } else { 131 | backupPdfViewerState(); 132 | } 133 | PDFViewerApplication.load(doc); 134 | } 135 | 136 | // Reference: https://github.com/James-Yu/LaTeX-Workshop/blob/master/viewer/latexworkshop.ts#L306 137 | function syncCode(pdf) { 138 | const _idx = Math.ceil(pdf.length / 2) - 1; 139 | const container = document.getElementById('viewerContainer'); 140 | const maxScrollX = window.innerWidth * 0.9; 141 | const minScrollX = window.innerWidth * 0.1; 142 | const pageNum = pdf[_idx].page; 143 | const h = pdf[_idx].h; 144 | const v = pdf[_idx].v; 145 | const page = document.getElementsByClassName('page')[pageNum - 1]; 146 | if (page === null || page === undefined) { 147 | return; 148 | } 149 | const {viewport} = PDFViewerApplication.pdfViewer.getPageView(pageNum - 1); 150 | let [left, top] = viewport.convertToPdfPoint(h , v); 151 | let scrollX = page.offsetLeft + left; 152 | scrollX = Math.min(scrollX, maxScrollX); 153 | scrollX = Math.max(scrollX, minScrollX); 154 | const scrollY = page.offsetTop + page.offsetHeight - top; 155 | if (PDFViewerApplication.pdfViewer.scrollMode === 1) { 156 | // horizontal scrolling 157 | container.scrollLeft = page.offsetLeft; 158 | } else { 159 | // vertical scrolling 160 | container.scrollTop = scrollY - document.body.offsetHeight * 0.4; 161 | } 162 | backupPdfViewerState(); 163 | } 164 | 165 | //Reference: https://github.com/overleaf/overleaf/blob/main/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.js#L163 166 | function syncPdf(pageElem, pageNum, clientX, clientY, innerText) { 167 | const pageCanvas = pageElem.querySelector('canvas'); 168 | const pageRect = pageCanvas.getBoundingClientRect(); 169 | const {viewport} = PDFViewerApplication.pdfViewer.getPageView(pageNum - 1); 170 | const dx = clientX - pageRect.left; 171 | const dy = clientY - pageRect.top; 172 | let [left, top] = viewport.convertToPdfPoint(dx, dy); 173 | top = viewport.viewBox[3] - top; 174 | vscode.postMessage({ 175 | type: 'syncPdf', 176 | content: { page: Number(pageNum), h: left, v: top, identifier: innerText}, 177 | }); 178 | backupPdfViewerState(); 179 | } 180 | 181 | window.addEventListener('load', async () => { 182 | // init pdf.js configuration 183 | PDFViewerApplication.initializedPromise 184 | .then(() => { 185 | const {eventBus, _boundEvents} = PDFViewerApplication; 186 | eventBus._off("beforeprint", _boundEvents.beforePrint); 187 | eventBus.on('documentloaded', updatePdfViewerState); 188 | // backup scale 189 | eventBus._on('scalechanged', backupPdfViewerState); 190 | eventBus._on("zoomin", backupPdfViewerState); 191 | eventBus._on("zoomout", backupPdfViewerState); 192 | eventBus._on("zoomreset", backupPdfViewerState); 193 | // backup scroll/spread mode 194 | eventBus._on("switchscrollmode", backupPdfViewerState); 195 | eventBus._on("scrollmodechanged", backupPdfViewerState); 196 | eventBus._on("switchspreadmode", backupPdfViewerState); 197 | vscode.postMessage({type: 'ready'}); 198 | }); 199 | 200 | // add message listener 201 | window.addEventListener('message', async (e) => { 202 | const message = e.data; 203 | switch (message.type) { 204 | case 'update': 205 | updatePdf(message.content); 206 | break; 207 | case 'syncCode': 208 | syncCode(message.content); 209 | break; 210 | case 'initState': 211 | if (message.content!==undefined) { 212 | Object.assign(globalPdfViewerState, message.content); 213 | } 214 | if (message.colorThemes!==undefined) { 215 | updateColorThemes(message.colorThemes); 216 | } 217 | updatePdfViewerState(); 218 | enableThemeToggleButton( Object.keys(ColorThemes).indexOf(globalPdfViewerState.colorTheme) ); 219 | break; 220 | default: 221 | break; 222 | } 223 | }); 224 | 225 | // add mouse double click listener 226 | window.addEventListener('dblclick', (e) => { 227 | const pageElem = e.target.parentElement.parentElement; 228 | const pageNum = pageElem.getAttribute('data-page-number'); 229 | if (pageNum === null || pageNum === undefined) { 230 | return; 231 | } 232 | syncPdf(pageElem, pageNum, e.clientX, e.clientY, e.target.innerText); 233 | }); 234 | 235 | // Display Error Message 236 | window.onerror = () => { 237 | const msg = document.createElement('body'); 238 | msg.innerText = 'An error occurred while loading the file. Please open it again.'; 239 | document.body = msg; 240 | }; 241 | }, { once : true }); 242 | 243 | }()); 244 | -------------------------------------------------------------------------------- /views/pdf-viewer/vendor/pdfjs-3.10.111-dist.patch: -------------------------------------------------------------------------------- 1 | diff --git a/build/pdf.js b/build/pdf.js 2 | index 8ec1e8f..b435c79 100644 3 | --- a/build/pdf.js 4 | +++ b/build/pdf.js 5 | @@ -362,6 +362,13 @@ const PasswordResponses = { 6 | INCORRECT_PASSWORD: 2 7 | }; 8 | exports.PasswordResponses = PasswordResponses; 9 | +const ViewerFontColor = '#000000'; 10 | +const LastViewerFontColor = '#000000'; 11 | +exports.ViewerFontColor = ViewerFontColor; 12 | +exports.LastViewerFontColor = LastViewerFontColor; 13 | +const ViewerBgColor = '#ffffff'; 14 | +exports.ViewerBgColor = ViewerBgColor; 15 | + 16 | let verbosity = VerbosityLevel.WARNINGS; 17 | function setVerbosityLevel(level) { 18 | if (Number.isInteger(level)) { 19 | @@ -6781,6 +6788,8 @@ class CanvasGraphics { 20 | this._cachedScaleForStroking = [-1, 0]; 21 | this._cachedGetSinglePixelWidth = null; 22 | this._cachedBitmapsMap = new Map(); 23 | + this.opStack = []; 24 | + this.opTrace = []; 25 | } 26 | getObject(data, fallback = null) { 27 | if (typeof data === "string") { 28 | @@ -6797,7 +6806,7 @@ class CanvasGraphics { 29 | const width = this.ctx.canvas.width; 30 | const height = this.ctx.canvas.height; 31 | const savedFillStyle = this.ctx.fillStyle; 32 | - this.ctx.fillStyle = background || "#ffffff"; 33 | + this.ctx.fillStyle = background || _util.ViewerBgColor; 34 | this.ctx.fillRect(0, 0, width, height); 35 | this.ctx.fillStyle = savedFillStyle; 36 | if (transparency) { 37 | @@ -6839,6 +6848,36 @@ class CanvasGraphics { 38 | return i; 39 | } 40 | fnId = fnArray[i]; 41 | + 42 | + // const opName = Object.entries(_util.OPS).find(([key, value]) => value === fnId)[0]; 43 | + // if (fnId===44) { 44 | + // this.opTrace.push([ opName, argsArray[i][0].map(x => x.unicode).join('') ]); 45 | + // } else { 46 | + // this.opTrace.push(opName); 47 | + // } 48 | + switch(fnId) { 49 | + case 74: //'paintFormXObjectBegin' 50 | + this.opStack.push(fnId); 51 | + break; 52 | + case 75: //'paintFormXObjectEnd' 53 | + this.opStack.pop(); 54 | + break; 55 | + } 56 | + const isInPainting = !(this.opStack.length===0); 57 | + if (fnId===44) { //'showText' 58 | + argsArray[i] = [ ...argsArray[i], isInPainting ]; 59 | + } 60 | + if (fnId===58) { //'setStrokeRGBColor' 61 | + const hexColor = _util.Util.makeHexColor(argsArray[i][0], argsArray[i][1], argsArray[i][2]).toUpperCase(); 62 | + const colorCondition = hexColor==='#000000' || hexColor===_util.LastViewerFontColor; 63 | + const enableCondition = colorCondition && !isInPainting && fnArray.length>10; 64 | + if (enableCondition) { 65 | + const viewerFontColor = _util.ViewerFontColor==='black' ? '#000000' : _util.ViewerFontColor; 66 | + const parsedRGBColor = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(viewerFontColor).slice(1).map(n => parseInt(n, 16)); 67 | + argsArray[i] = parsedRGBColor; 68 | + } 69 | + } 70 | + 71 | if (fnId !== _util.OPS.dependency) { 72 | this[fnId].apply(this, argsArray[i]); 73 | } else { 74 | @@ -7500,7 +7539,7 @@ class CanvasGraphics { 75 | } 76 | return (0, _util.shadow)(this, "isFontSubpixelAAEnabled", enabled); 77 | } 78 | - showText(glyphs) { 79 | + showText(glyphs, isInPainting=false) { 80 | const current = this.current; 81 | const font = current.font; 82 | if (font.isType3Font) { 83 | @@ -7605,7 +7644,12 @@ class CanvasGraphics { 84 | } 85 | if (this.contentVisible && (glyph.isInFont || font.missingFile)) { 86 | if (simpleFillText && !accent) { 87 | + const _style = ctx.fillStyle; 88 | + if (ctx.fillStyle==='#000000' && !isInPainting) { 89 | + ctx.fillStyle = _util.ViewerFontColor; 90 | + } 91 | ctx.fillText(character, scaledX, scaledY); 92 | + ctx.fillStyle = _style; 93 | } else { 94 | this.paintChar(character, scaledX, scaledY, patternTransform); 95 | if (accent) { 96 | @@ -8888,6 +8932,7 @@ GlobalWorkerOptions.workerPort = null; 97 | GlobalWorkerOptions.workerSrc = ""; 98 | 99 | /***/ }), 100 | + 101 | /* 15 */ 102 | /***/ ((__unused_webpack_module, exports, __w_pdfjs_require__) => { 103 | 104 | @@ -11426,7 +11471,7 @@ class SVGGraphics { 105 | this.setFont(args); 106 | break; 107 | case _util.OPS.showText: 108 | - this.showText(args[0]); 109 | + this.showText(args[0], args[1]); 110 | break; 111 | case _util.OPS.showSpacedText: 112 | this.showText(args[0]); 113 | @@ -17651,6 +17696,34 @@ Object.defineProperty(exports, "GlobalWorkerOptions", ({ 114 | return _worker_options.GlobalWorkerOptions; 115 | } 116 | })); 117 | +Object.defineProperty(exports, "ViewerFontColor", ({ 118 | + enumerable: true, 119 | + get: function () { 120 | + return _util.ViewerFontColor; 121 | + }, 122 | + set: function (color) { 123 | + _util.LastViewerFontColor = _util.ViewerFontColor; 124 | + _util.ViewerFontColor = color; 125 | + } 126 | +})); 127 | +Object.defineProperty(exports, "LastViewerFontColor", ({ 128 | + enumerable: true, 129 | + get: function () { 130 | + return _util.LastViewerFontColor; 131 | + }, 132 | + set: function (color) { 133 | + _util.LastViewerFontColor = color; 134 | + } 135 | +})); 136 | +Object.defineProperty(exports, "ViewerBgColor", ({ 137 | + enumerable: true, 138 | + get: function () { 139 | + return _util.ViewerBgColor; 140 | + }, 141 | + set: function (color) { 142 | + _util.ViewerBgColor = color; 143 | + } 144 | +})); 145 | Object.defineProperty(exports, "ImageKind", ({ 146 | enumerable: true, 147 | get: function () { 148 | diff --git a/web/viewer.js b/web/viewer.js 149 | index 6d1fb61..7feba07 100644 150 | --- a/web/viewer.js 151 | +++ b/web/viewer.js 152 | @@ -8387,7 +8387,7 @@ class PDFViewer { 153 | this.#annotationEditorMode = options.annotationEditorMode ?? _pdfjsLib.AnnotationEditorType.NONE; 154 | this.imageResourcesPath = options.imageResourcesPath || ""; 155 | this.enablePrintAutoRotate = options.enablePrintAutoRotate || false; 156 | - this.removePageBorders = options.removePageBorders || false; 157 | + this.removePageBorders = options.removePageBorders || true; 158 | if (options.useOnlyCssZoom) { 159 | console.error("useOnlyCssZoom was removed, please use `maxCanvasPixels = 0` instead."); 160 | options.maxCanvasPixels = 0; 161 | --------------------------------------------------------------------------------