├── .gitignore ├── .prettierrc.json ├── images ├── icon.png ├── icon-small.png ├── star.svg ├── dark │ ├── code-tour.svg │ ├── code-snippet.svg │ ├── note.svg │ ├── diagram.svg │ ├── tag.svg │ ├── code-snippet-secret.svg │ ├── code-tour-secret.svg │ ├── code-swing-secret.svg │ ├── flash-code.svg │ ├── code-swing-template-secret.svg │ ├── code-swing.svg │ ├── code-swing-template.svg │ ├── note-secret.svg │ ├── diagram-secret.svg │ ├── sort-time.svg │ ├── notebook.svg │ ├── flash-code-secret.svg │ ├── code-swing-tutorial.svg │ ├── notebook-secret.svg │ └── sort-alphabetical.svg ├── light │ ├── code-snippet.svg │ ├── code-tour.svg │ ├── note.svg │ ├── diagram.svg │ ├── tag.svg │ ├── code-snippet-secret.svg │ ├── code-tour-secret.svg │ ├── code-swing-secret.svg │ ├── flash-code.svg │ ├── code-swing-template-secret.svg │ ├── code-swing.svg │ ├── code-swing-template.svg │ ├── note-secret.svg │ ├── diagram-secret.svg │ ├── sort-time.svg │ ├── notebook.svg │ ├── flash-code-secret.svg │ ├── code-swing-tutorial.svg │ ├── notebook-secret.svg │ └── sort-alphabetical.svg ├── icon-activity.svg └── daily.svg ├── .devcontainer └── devcontainer.json ├── src ├── abstractions │ ├── node │ │ └── images │ │ │ ├── utils │ │ │ ├── randomInt.ts │ │ │ ├── createUploadMarkup.ts │ │ │ ├── createImageMarkup.ts │ │ │ └── pasteImageMarkup.ts │ │ │ ├── scripts │ │ │ ├── linux.sh │ │ │ ├── win.ps1 │ │ │ └── mac.applescript │ │ │ ├── pasteImageAsBase64.ts │ │ │ ├── pasteImage.ts │ │ │ ├── pasteImageAsFile.ts │ │ │ └── clipboardToImageBuffer.ts │ └── browser │ │ ├── simple-git.ts │ │ └── images │ │ └── pasteImage.ts ├── repos │ ├── utils.ts │ ├── tours │ │ ├── index.ts │ │ ├── actions.ts │ │ └── commands.ts │ ├── wiki │ │ ├── config.ts │ │ ├── index.ts │ │ ├── statusBar.ts │ │ ├── hoverProvider.ts │ │ ├── markdownPreview.ts │ │ ├── linkProvider.ts │ │ ├── actions.ts │ │ ├── utils.ts │ │ ├── completionProvider.ts │ │ ├── comments.ts │ │ └── commands.ts │ ├── index.ts │ ├── store │ │ └── storage.ts │ ├── comments │ │ ├── actions.ts │ │ ├── commands.ts │ │ └── index.ts │ └── tree │ │ ├── nodes.ts │ │ └── index.ts ├── showcase │ ├── store.ts │ ├── index.ts │ └── tree.ts ├── commands │ ├── tour.ts │ ├── auth.ts │ ├── index.ts │ ├── follow.ts │ ├── daily.ts │ ├── notebook.ts │ └── comments.ts ├── constants.ts ├── output.ts ├── config.ts ├── mcp.ts ├── extension.ts ├── fileSystem │ ├── api.ts │ └── git.ts ├── store │ ├── storage.ts │ ├── index.ts │ └── auth.ts ├── tour.ts ├── comments │ └── index.ts ├── swings │ └── index.ts └── uriHandler.ts ├── .vscodeignore ├── .github ├── pull_request_template.md ├── workflows │ ├── PublishToMarketplace.yml │ ├── release.yml │ └── codeql-analysis.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .vscode ├── settings.json ├── launch.json └── tasks.json ├── tslint.json ├── tsconfig.json ├── CODE_OF_CONDUCT.md ├── manifests └── showcase.json ├── LICENSE.txt ├── CONTRIBUTING.md └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | *.vsix 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "trailingComma": "none" 4 | } 5 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostintangent/gistpad/HEAD/images/icon.png -------------------------------------------------------------------------------- /images/icon-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostintangent/gistpad/HEAD/images/icon-small.png -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensions": [ 3 | "lostintangent.github-security-alerts" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/abstractions/node/images/utils/randomInt.ts: -------------------------------------------------------------------------------- 1 | export const randomInt = (base = 1000000) => { 2 | return Math.round(Math.random() * base); 3 | } 4 | -------------------------------------------------------------------------------- /src/abstractions/browser/simple-git.ts: -------------------------------------------------------------------------------- 1 | export function notAvailable(): never { 2 | throw new Error('simple-git is not available in the browser build'); 3 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .github/** 3 | src/** 4 | .gitignore 5 | **/webpack.config.js 6 | **/tsconfig.json 7 | **/tslint.json 8 | **/prettierrc.json 9 | **/*.map 10 | **/*.ts 11 | node_modules/** -------------------------------------------------------------------------------- /src/abstractions/browser/images/pasteImage.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export async function pasteImageCommand() { 4 | vscode.window.showErrorMessage("Paste image is not supported in the browser"); 5 | } 6 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **This PR fixes Issue#** 2 | 3 | 4 | **Description of the PR**: 5 | A clear and concise description of what your solution does. 6 | 7 | 8 | **Additional context**: 9 | Add any other context or screenshots about the PR here. 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": "explicit", 4 | "source.fixAll": "explicit" 5 | }, 6 | "editor.formatOnSave": true, 7 | "files.exclude": { 8 | "out": false 9 | }, 10 | "search.exclude": { 11 | "out": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-string-throw": true, 4 | "no-unused-expression": true, 5 | "no-duplicate-variable": true, 6 | "curly": true, 7 | "class-name": true, 8 | "semicolon": [true, "always"], 9 | "triple-equals": true 10 | }, 11 | "defaultSeverity": "warning" 12 | } 13 | -------------------------------------------------------------------------------- /src/repos/utils.ts: -------------------------------------------------------------------------------- 1 | import { commands } from "vscode"; 2 | import { RepoFileSystemProvider } from "./fileSystem"; 3 | 4 | export function openRepoDocument(repo: string, file: string) { 5 | const uri = RepoFileSystemProvider.getFileUri(repo, file); 6 | commands.executeCommand("vscode.open", uri); 7 | } 8 | 9 | export function sanitizeName(name: string) { 10 | return name.replace(/\s/g, "-").replace(/[^\w\d-_]/g, ""); 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Extension (watch)", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "runtimeExecutable": "${execPath}", 9 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 10 | "outFiles": ["${workspaceFolder}/out/prod/**/*.js"], 11 | "preLaunchTask": "build-watch", 12 | "smartStep": true 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "ES2019", 6 | "outDir": "out", 7 | "lib": ["ES2019", "DOM"], 8 | "sourceMap": true, 9 | "rootDir": "src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "@abstractions/*": ["src/abstractions/node/*"] 15 | }, 16 | "experimentalDecorators": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Microsoft Open Source Code of Conduct 3 | 4 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 5 | 6 | Resources: 7 | 8 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 9 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 10 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 11 | -------------------------------------------------------------------------------- /.github/workflows/PublishToMarketplace.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | 4 | name: Publish to VSCode Marketplace 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | - name: Publish to Visual Studio Marketplace 12 | uses: HaaLeo/publish-vscode-extension@v1 13 | with: 14 | pat: ${{ secrets.VSCE_PAT }} 15 | registryUrl: https://marketplace.visualstudio.com 16 | -------------------------------------------------------------------------------- /src/repos/tours/index.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from "vscode"; 2 | import { onDidEndTour } from "../../tour"; 3 | import { REPO_SCHEME } from "../fileSystem"; 4 | import { store } from "../store"; 5 | import { registerTourCommands } from "./commands"; 6 | 7 | export async function registerTourController(context: ExtensionContext) { 8 | registerTourCommands(context); 9 | 10 | onDidEndTour((tour) => { 11 | if (tour.id.startsWith(`${REPO_SCHEME}:/`)) { 12 | store.isInCodeTour = false; 13 | } 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /images/star.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/showcase/store.ts: -------------------------------------------------------------------------------- 1 | import { observable } from "mobx"; 2 | import { Gist } from "../store"; 3 | 4 | export interface GistShowcaseCategory { 5 | title: string; 6 | gists: Gist[]; 7 | isLoading: boolean; 8 | } 9 | 10 | export interface GistShowcase { 11 | categories: GistShowcaseCategory[]; 12 | isLoading: boolean; 13 | } 14 | 15 | export interface Store { 16 | showcase: GistShowcase; 17 | } 18 | 19 | export const store: Store = observable({ 20 | showcase: { 21 | categories: [], 22 | isLoading: false 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | Release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Install Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 14.x 18 | - run: npm install 19 | - name: Publish to VS Marketplace 20 | run: npx vsce publish 21 | env: 22 | VSCE_PAT: ${{ secrets.VSCE_PAT }} 23 | -------------------------------------------------------------------------------- /src/abstractions/node/images/scripts/linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # require xclip(see http://stackoverflow.com/questions/592620/check-if-a-program-exists-from-a-bash-script/677212#677212) 4 | command -v xclip >/dev/null 2>&1 || { echo >&1 "no xclip"; exit 1; } 5 | 6 | # write image in clipboard to file (see http://unix.stackexchange.com/questions/145131/copy-image-from-clipboard-to-file) 7 | if 8 | xclip -selection clipboard -target image/png -o >/dev/null 2>&1 9 | then 10 | xclip -selection clipboard -target image/png -o >$1 2>/dev/null 11 | echo $1 12 | else 13 | echo "no image" 14 | fi -------------------------------------------------------------------------------- /src/repos/wiki/config.ts: -------------------------------------------------------------------------------- 1 | import { workspace } from "vscode"; 2 | import { EXTENSION_NAME } from "../../constants"; 3 | 4 | function getConfigSetting(settingName: string) { 5 | return workspace.getConfiguration(EXTENSION_NAME).get(`wikis.${settingName}`); 6 | } 7 | 8 | export const config = { 9 | get dailyDirectName() { 10 | return getConfigSetting("daily.directoryName"); 11 | }, 12 | get dailyTitleFormat() { 13 | return getConfigSetting("daily.titleFormat"); 14 | }, 15 | get dailyFilenameFormat() { 16 | return getConfigSetting("daily.filenameFormat"); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/commands/tour.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { EXTENSION_NAME } from "../constants"; 3 | import { exportTour, TOUR_FILE } from "../tour"; 4 | import { promptForGistSelection } from "./editor"; 5 | 6 | export async function registerTourCommands(context: vscode.ExtensionContext) { 7 | context.subscriptions.push( 8 | vscode.commands.registerCommand( 9 | `${EXTENSION_NAME}.exportTour`, 10 | async ({ tour }) => { 11 | const content = await exportTour(tour); 12 | promptForGistSelection([{ filename: TOUR_FILE, content }]); 13 | } 14 | ) 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/abstractions/node/images/utils/createUploadMarkup.ts: -------------------------------------------------------------------------------- 1 | import { DocumentLanguages } from "../pasteImage"; 2 | 3 | export function createUploadMarkup( 4 | id: string | number, 5 | isUploading: boolean, 6 | languageId: string 7 | ) { 8 | const pasteDescription = isUploading ? "Uploading" : "Creating"; 9 | const message = `${pasteDescription} image ${id}...`; 10 | 11 | switch (languageId) { 12 | case DocumentLanguages.html: 13 | return ``; 14 | case DocumentLanguages.pug: 15 | return `//- ${message} //`; 16 | default: 17 | return `**${message}**`; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build:node", 7 | "problemMatcher": ["$gulp-tsc"], 8 | "group": "build" 9 | }, 10 | { 11 | "label": "build-watch", 12 | "type": "npm", 13 | "script": "watch", 14 | "problemMatcher": { 15 | "base": "$tsc-watch", 16 | "background": { 17 | "beginsPattern": "\\d{1,2}%\\sbuilding", 18 | "endsPattern": "Hash:\\s[a-f0-9]{20}" 19 | } 20 | }, 21 | "isBackground": true, 22 | "group": "build" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/abstractions/node/images/scripts/win.ps1: -------------------------------------------------------------------------------- 1 | param($imagePath) 2 | 3 | # Adapted from https://github.com/octan3/img-clipboard-dump/blob/master/dump-clipboard-png.ps1 4 | 5 | Add-Type -Assembly PresentationCore 6 | $img = [Windows.Clipboard]::GetImage() 7 | 8 | if ($img -eq $null) { 9 | "no image" 10 | Exit 1 11 | } 12 | 13 | $fcb = new-object Windows.Media.Imaging.FormatConvertedBitmap($img, [Windows.Media.PixelFormats]::Rgb24, $null, 0) 14 | $stream = [IO.File]::Open($imagePath, "OpenOrCreate") 15 | $encoder = New-Object Windows.Media.Imaging.PngBitmapEncoder 16 | $encoder.Frames.Add([Windows.Media.Imaging.BitmapFrame]::Create($fcb)) | out-null 17 | $encoder.Save($stream) | out-null 18 | $stream.Dispose() | out-null 19 | 20 | $imagePath -------------------------------------------------------------------------------- /images/dark/code-tour.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | gist 5 | Created with Sketch. 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /images/dark/code-snippet.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | gist 5 | Created with Sketch. 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /images/light/code-snippet.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | gist 5 | Created with Sketch. 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /images/light/code-tour.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | gist 5 | Created with Sketch. 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/repos/wiki/index.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from "vscode"; 2 | import { registerCommands } from "./commands"; 3 | import { registerCommentController } from "./comments"; 4 | import { registerLinkCompletionProvider } from "./completionProvider"; 5 | import { registerLinkDecorator } from "./decorator"; 6 | import { registerHoverProvider } from "./hoverProvider"; 7 | import { registerDocumentLinkProvider } from "./linkProvider"; 8 | import { registerStatusBar } from "./statusBar"; 9 | 10 | export function registerWikiController(context: ExtensionContext) { 11 | registerCommands(context); 12 | registerLinkDecorator(); 13 | registerHoverProvider(); 14 | 15 | registerLinkCompletionProvider(); 16 | registerDocumentLinkProvider(); 17 | 18 | registerCommentController(); 19 | registerStatusBar(); 20 | } 21 | -------------------------------------------------------------------------------- /.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 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - VS Code Version [e.g. v1.44.0] 28 | - GistPad Version [e.g. v0.0.66] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const EXTENSION_NAME = "gistpad"; 2 | export const EXTENSION_ID = "vsls-contrib.gistfs"; 3 | 4 | export const FS_SCHEME = "gist"; 5 | export const INPUT_SCHEME = "input"; 6 | 7 | export const SWING_FILE = "codeswing.json"; 8 | export const UNTITLED_SCHEME = "untitled"; 9 | 10 | export const URI_PATTERN = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; 11 | 12 | export const ZERO_WIDTH_SPACE = "\u{2064}"; 13 | 14 | export const DIRECTORY_SEPARATOR = "/"; 15 | export const ENCODED_DIRECTORY_SEPARATOR = "---"; 16 | 17 | // This is the ID of the "magic" Gist that we create 18 | // in order to store daily notes on behalf of the end-user. 19 | export const DAILY_GIST_NAME = "📆 Daily notes"; 20 | export const DAILY_TEMPLATE_FILENAME = "template.md"; 21 | -------------------------------------------------------------------------------- /src/abstractions/node/images/utils/createImageMarkup.ts: -------------------------------------------------------------------------------- 1 | import * as config from "../../../../config"; 2 | import { DocumentLanguages } from "../pasteImage"; 3 | 4 | export async function createImageMarkup( 5 | imageSrc: string, 6 | languageId: string, 7 | imageAlt = "image" 8 | ) { 9 | const htmlMarkup = `${imageAlt}`; 10 | 11 | switch (languageId) { 12 | case DocumentLanguages.markdown: 13 | const imageOutput = config.get("images.markdownPasteFormat"); 14 | return imageOutput === DocumentLanguages.markdown 15 | ? `![${imageAlt}](${imageSrc})` 16 | : htmlMarkup; 17 | case DocumentLanguages.pug: 18 | return `img(src='${imageSrc}', alt='${imageAlt}')`; 19 | case DocumentLanguages.html: 20 | default: 21 | return htmlMarkup; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/auth.ts: -------------------------------------------------------------------------------- 1 | import { commands, env, ExtensionContext, Uri } from "vscode"; 2 | import { EXTENSION_NAME } from "../constants"; 3 | import { signIn } from "../store/auth"; 4 | import { FollowedUserGistsNode, GistsNode } from "../tree/nodes"; 5 | 6 | export async function registerAuthCommands(context: ExtensionContext) { 7 | context.subscriptions.push( 8 | commands.registerCommand(`${EXTENSION_NAME}.signIn`, signIn) 9 | ); 10 | 11 | context.subscriptions.push( 12 | commands.registerCommand( 13 | `${EXTENSION_NAME}.openProfile`, 14 | async (node: GistsNode | FollowedUserGistsNode) => { 15 | const login = 16 | node instanceof GistsNode ? node.login : node.user.username; 17 | const uri = Uri.parse(`https://gist.github.com/${login}`); 18 | env.openExternal(uri); 19 | } 20 | ) 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /manifests/showcase.json: -------------------------------------------------------------------------------- 1 | { 2 | "categories": [ 3 | { 4 | "title": "Swings", 5 | "gists": [ 6 | "4366463886437d01568a04a6885522f1", 7 | "f375af6bd8444c728b803f9d69cb58f7", 8 | "ecdf56c499e809ad671c4d85d5c2b90a", 9 | "9bd7c0d391237874fcc9c8c216c86c4d" 10 | ] 11 | }, 12 | { 13 | "title": "Tutorials", 14 | "gists": ["c3bcd4bff4a13b2e1b3fc4a26332e2b6"] 15 | }, 16 | { 17 | "title": "Data Visualizations", 18 | "gists": [ 19 | "6459889", 20 | "1341281", 21 | "ded69192b8269a78d2d97e24211e64e0", 22 | "41a7adb028bb10c741153f58b36d01fe" 23 | ] 24 | }, 25 | { 26 | "title": "Code Samples / Starters", 27 | "gists": [ 28 | "1bfc2d4aecb01a834b46", 29 | "c4d472ce3c61feec6376", 30 | "cc3f9baecd324026931ae9544e1aaf62" 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/abstractions/node/images/pasteImageAsBase64.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { clipboardToImageBuffer } from "./clipboardToImageBuffer"; 3 | import { createImageMarkup } from "./utils/createImageMarkup"; 4 | import { pasteImageMarkup } from "./utils/pasteImageMarkup"; 5 | 6 | function createBase64ImageSource(imageBits: Buffer) { 7 | const base64Str = imageBits.toString("base64"); 8 | return `data:image/png;base64,${base64Str}`; 9 | } 10 | 11 | export async function pasteImageAsBase64( 12 | editor: vscode.TextEditor, 13 | imageMarkupId: string | number 14 | ) { 15 | const imageBits = await clipboardToImageBuffer.getImageBits(); 16 | const base64Image = createBase64ImageSource(imageBits); 17 | const imageMarkup = await createImageMarkup( 18 | base64Image, 19 | editor.document.languageId 20 | ); 21 | 22 | await pasteImageMarkup(editor, imageMarkup, imageMarkupId); 23 | } 24 | -------------------------------------------------------------------------------- /src/abstractions/node/images/scripts/mac.applescript: -------------------------------------------------------------------------------- 1 | property fileTypes : {{«class PNGf», ".png"}} 2 | 3 | on run argv 4 | if argv is {} then 5 | return "" 6 | end if 7 | 8 | set imagePath to (item 1 of argv) 9 | set theType to getType() 10 | 11 | if theType is not missing value then 12 | try 13 | set myFile to (open for access imagePath with write permission) 14 | set eof myFile to 0 15 | write (the clipboard as (first item of theType)) to myFile 16 | close access myFile 17 | return (POSIX path of imagePath) 18 | on error 19 | try 20 | close access myFile 21 | end try 22 | return "" 23 | end try 24 | else 25 | return "no image" 26 | end if 27 | end run 28 | 29 | on getType() 30 | repeat with aType in fileTypes 31 | repeat with theInfo in (clipboard info) 32 | if (first item of theInfo) is equal to (first item of aType) then return aType 33 | end repeat 34 | end repeat 35 | return missing value 36 | end getType 37 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from "vscode"; 2 | import { registerAuthCommands } from "./auth"; 3 | import { registerCommentCommands } from "./comments"; 4 | import { registerDailyCommands } from "./daily"; 5 | import { registerDirectoryCommands } from "./directory"; 6 | import { registerEditorCommands } from "./editor"; 7 | import { registerFileCommands } from "./file"; 8 | import { registerFollowCommands } from "./follow"; 9 | import { registerGistCommands } from "./gist"; 10 | import { registerNotebookCommands } from "./notebook"; 11 | import { registerTourCommands } from "./tour"; 12 | 13 | export function registerCommands(context: ExtensionContext) { 14 | registerAuthCommands(context); 15 | registerCommentCommands(context); 16 | registerDirectoryCommands(context); 17 | registerEditorCommands(context); 18 | registerFileCommands(context); 19 | registerFollowCommands(context); 20 | registerGistCommands(context); 21 | registerNotebookCommands(context); 22 | registerDailyCommands(context); 23 | registerTourCommands(context); 24 | } 25 | -------------------------------------------------------------------------------- /src/repos/index.ts: -------------------------------------------------------------------------------- 1 | import { when } from "mobx"; 2 | import { ExtensionContext } from "vscode"; 3 | import { store } from "../store"; 4 | import { isCodeTourInstalled } from "../tour"; 5 | import { registerRepoCommands } from "./commands"; 6 | import { registerCommentController } from "./comments"; 7 | import { registerRepoFileSystemProvider } from "./fileSystem"; 8 | import { refreshRepositories } from "./store/actions"; 9 | import { initializeStorage } from "./store/storage"; 10 | import { registerTourController } from "./tours"; 11 | import { registerTreeProvider } from "./tree"; 12 | import { registerWikiController } from "./wiki"; 13 | 14 | export async function registerRepoModule(context: ExtensionContext) { 15 | registerRepoCommands(context); 16 | 17 | registerRepoFileSystemProvider(); 18 | registerTreeProvider(); 19 | 20 | initializeStorage(context); 21 | 22 | registerCommentController(context); 23 | registerWikiController(context); 24 | 25 | if (await isCodeTourInstalled()) { 26 | registerTourController(context); 27 | } 28 | 29 | await when(() => store.isSignedIn); 30 | refreshRepositories(); 31 | } 32 | -------------------------------------------------------------------------------- /src/abstractions/node/images/utils/pasteImageMarkup.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as config from "../../../../config"; 3 | import { createUploadMarkup } from "./createUploadMarkup"; 4 | 5 | export async function pasteImageMarkup( 6 | editor: vscode.TextEditor, 7 | imageMarkup: string, 8 | imageMarkupId: string | number 9 | ) { 10 | const uploadSetting = config.get("images.pasteType"); 11 | const isUploading = uploadSetting === "file"; 12 | 13 | await editor.edit(async (edit) => { 14 | const { document, selection } = editor; 15 | const text = document.getText(); 16 | 17 | const markup = createUploadMarkup( 18 | imageMarkupId, 19 | isUploading, 20 | document.languageId 21 | ); 22 | 23 | const index = text.indexOf(markup); 24 | if (index === -1) { 25 | edit.insert(selection.start, imageMarkup); 26 | 27 | return; 28 | } 29 | 30 | const startPos = document.positionAt(index); 31 | const endPos = document.positionAt(index + markup.length); 32 | const range = new vscode.Selection(startPos, endPos); 33 | 34 | edit.replace(range, imageMarkup); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) Jonathan Carter and Contributors. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/repos/store/storage.ts: -------------------------------------------------------------------------------- 1 | import { commands, ExtensionContext } from "vscode"; 2 | import { output } from "../../extension"; 3 | 4 | const HASREPOS_CONTEXT = "gistpad:hasRepos"; 5 | const REPO_KEY = "gistpad.repos"; 6 | 7 | export interface IStorage { 8 | repos: string[]; 9 | } 10 | 11 | export let reposStorage: IStorage; 12 | export async function initializeStorage(context: ExtensionContext) { 13 | reposStorage = { 14 | get repos(): string[] { 15 | output?.appendLine( 16 | `Getting repos from global state = ${context.globalState.get( 17 | REPO_KEY, 18 | [] 19 | )}`, 20 | output?.messageType.Info 21 | ); 22 | 23 | return context.globalState.get(REPO_KEY, []); 24 | }, 25 | set repos(repos: string[]) { 26 | output?.appendLine(`Setting repos to ${repos}`, output?.messageType.Info); 27 | context.globalState.update(REPO_KEY, repos); 28 | commands.executeCommand("setContext", HASREPOS_CONTEXT, repos.length > 0); 29 | } 30 | }; 31 | 32 | commands.executeCommand( 33 | "setContext", 34 | HASREPOS_CONTEXT, 35 | reposStorage.repos.length > 0 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/repos/tours/actions.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { promptForTour as prompt } from "../../tour"; 3 | import { RepoFileSystemProvider } from "../fileSystem"; 4 | import { Repository, store } from "../store"; 5 | 6 | export async function loadRepoTours( 7 | repository: Repository 8 | ): Promise<[any[], vscode.Uri]> { 9 | const workspaceRoot = RepoFileSystemProvider.getFileUri(repository.name); 10 | 11 | const tours = []; 12 | for (let tourPath of repository.tours) { 13 | const uri = RepoFileSystemProvider.getFileUri(repository.name, tourPath); 14 | const tourContents = await ( 15 | await vscode.workspace.fs.readFile(uri) 16 | ).toString(); 17 | const tour = JSON.parse(tourContents); 18 | tour.id = uri.toString(); 19 | 20 | tours.push(tour); 21 | } 22 | 23 | return [tours, workspaceRoot]; 24 | } 25 | 26 | export async function promptForTour(repository: Repository) { 27 | if (repository.hasTours) { 28 | const [tours, workspaceRoot] = await loadRepoTours(repository); 29 | const selectedTour = await prompt(workspaceRoot, tours); 30 | if (selectedTour) { 31 | store.isInCodeTour = true; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/repos/wiki/statusBar.ts: -------------------------------------------------------------------------------- 1 | import { reaction } from "mobx"; 2 | import { commands, window } from "vscode"; 3 | import { store } from "../store"; 4 | 5 | export function registerStatusBar() { 6 | const openTodayPageStatusBarItem = window.createStatusBarItem(); 7 | openTodayPageStatusBarItem.command = "gistpad.openTodayPage"; 8 | openTodayPageStatusBarItem.text = "$(calendar)"; 9 | 10 | const addWikiPageStatusBarItem = window.createStatusBarItem(); 11 | addWikiPageStatusBarItem.command = "gistpad.addWikiPage"; 12 | addWikiPageStatusBarItem.text = "$(notebook)"; 13 | 14 | reaction( 15 | () => [store.wiki], 16 | () => { 17 | commands.executeCommand("setContext", "gistpad:hasWiki", !!store.wiki); 18 | 19 | if (store.wiki) { 20 | openTodayPageStatusBarItem.tooltip = `GistPad: Open today page (${store.wiki.fullName})`; 21 | openTodayPageStatusBarItem.show(); 22 | 23 | addWikiPageStatusBarItem.tooltip = `GistPad: Add Wiki Page (${store.wiki.fullName})`; 24 | addWikiPageStatusBarItem.show(); 25 | } else { 26 | openTodayPageStatusBarItem.hide(); 27 | addWikiPageStatusBarItem.hide(); 28 | } 29 | } 30 | ); 31 | } -------------------------------------------------------------------------------- /src/repos/tours/commands.ts: -------------------------------------------------------------------------------- 1 | import { commands, ExtensionContext } from "vscode"; 2 | import { EXTENSION_NAME } from "../../constants"; 3 | import { recordTour, selectTour } from "../../tour"; 4 | import { RepoFileSystemProvider } from "../fileSystem"; 5 | import { store } from "../store"; 6 | import { RepositoryNode } from "../tree/nodes"; 7 | import { loadRepoTours } from "./actions"; 8 | 9 | export async function registerTourCommands(context: ExtensionContext) { 10 | context.subscriptions.push( 11 | commands.registerCommand( 12 | `${EXTENSION_NAME}.recordRepoCodeTour`, 13 | async (node: RepositoryNode) => { 14 | store.isInCodeTour = true; 15 | 16 | const uri = RepoFileSystemProvider.getFileUri(node.repo.name); 17 | recordTour(uri); 18 | } 19 | ) 20 | ); 21 | 22 | context.subscriptions.push( 23 | commands.registerCommand( 24 | `${EXTENSION_NAME}.startRepoCodeTour`, 25 | async (node: RepositoryNode) => { 26 | const [tours, workspaceRoot] = await loadRepoTours(node.repo); 27 | if (await selectTour(tours, workspaceRoot)) { 28 | store.isInCodeTour = true; 29 | } 30 | } 31 | ) 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/repos/comments/actions.ts: -------------------------------------------------------------------------------- 1 | import { getApi } from "../../store/actions"; 2 | 3 | export async function createRepoComment( 4 | repo: string, 5 | path: string, 6 | body: string, 7 | line: number 8 | ) { 9 | const GitHub = require("github-base"); 10 | const api = await getApi(GitHub); 11 | 12 | const response = await api.post(`/repos/${repo}/commits/HEAD/comments`, { 13 | body, 14 | path, 15 | line 16 | }); 17 | return response.body; 18 | } 19 | 20 | export async function editRepoComment(repo: string, id: string, body: string) { 21 | const GitHub = require("github-base"); 22 | const api = await getApi(GitHub); 23 | 24 | return api.patch(`/repos/${repo}/comments/${id}`, { 25 | body 26 | }); 27 | } 28 | 29 | export async function deleteRepoComment(repo: string, id: string) { 30 | const GitHub = require("github-base"); 31 | const api = await getApi(GitHub); 32 | 33 | return api.delete(`/repos/${repo}/comments/${id}`); 34 | } 35 | 36 | export async function getRepoComments(repo: string) { 37 | const GitHub = require("github-base"); 38 | const api = await getApi(GitHub); 39 | 40 | const response = await api.get(`/repos/${repo}/comments`); 41 | return response.body; 42 | } 43 | -------------------------------------------------------------------------------- /images/dark/note.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | pencil-gray 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /images/light/note.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | blog-post 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/commands/follow.ts: -------------------------------------------------------------------------------- 1 | import { commands, env, ExtensionContext, window } from "vscode"; 2 | import { EXTENSION_NAME } from "../constants"; 3 | import { followUser, unfollowUser } from "../store/actions"; 4 | import { FollowedUserGistsNode } from "../tree/nodes"; 5 | 6 | export async function registerFollowCommands(context: ExtensionContext) { 7 | context.subscriptions.push( 8 | commands.registerCommand(`${EXTENSION_NAME}.followUser`, async () => { 9 | const value = await env.clipboard.readText(); 10 | const username = await window.showInputBox({ 11 | prompt: "Specify the name of the user you'd like to follow", 12 | value 13 | }); 14 | 15 | if (username) { 16 | await followUser(username); 17 | } 18 | }) 19 | ); 20 | 21 | context.subscriptions.push( 22 | commands.registerCommand( 23 | `${EXTENSION_NAME}.unfollowUser`, 24 | async ( 25 | targetNode: FollowedUserGistsNode, 26 | multiSelectNodes?: FollowedUserGistsNode[] 27 | ) => { 28 | const nodes = multiSelectNodes || [targetNode]; 29 | 30 | for (const node of nodes) { 31 | const username = node.user.username; 32 | await unfollowUser(username); 33 | } 34 | } 35 | ) 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/output.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as config from "./config"; 3 | 4 | export enum messageType { 5 | Info = "Info", 6 | Warning = "Warning", 7 | Error = "Error", 8 | Verbose = "Verbose", 9 | Debug = "Debug" 10 | } 11 | 12 | export class Output { 13 | private _outputChannel: any; 14 | public messageType = messageType; 15 | 16 | constructor() { 17 | this._outputChannel = vscode.window.createOutputChannel("GistPad"); 18 | } 19 | 20 | private getDate(): string { 21 | const date = new Date(); 22 | let timePart = `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}:${date.getMilliseconds()}`; 23 | let datePart = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; // prettier-ignore 24 | return `${datePart} ${timePart}`; 25 | } 26 | 27 | public appendLine(message: string, messageType: messageType) { 28 | if (config.get("output")) { 29 | this._outputChannel.appendLine( 30 | `${this.getDate()} - ${messageType}: ${message}` 31 | ); 32 | } 33 | } 34 | 35 | public hide() { 36 | this._outputChannel.hide(); 37 | } 38 | 39 | public show() { 40 | this._outputChannel.show(); 41 | } 42 | 43 | public dispose() { 44 | this._outputChannel.dispose(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/commands/daily.ts: -------------------------------------------------------------------------------- 1 | import { commands, ExtensionContext, window } from "vscode"; 2 | import * as config from "../config"; 3 | import { EXTENSION_NAME } from "../constants"; 4 | import { store } from "../store"; 5 | import { clearDailyNotes, openDailyTemplate, openTodayNote } from "../store/actions"; 6 | 7 | export async function registerDailyCommands(context: ExtensionContext) { 8 | context.subscriptions.push( 9 | commands.registerCommand(`${EXTENSION_NAME}.openTodayNote`, openTodayNote) 10 | ); 11 | 12 | context.subscriptions.push( 13 | commands.registerCommand(`${EXTENSION_NAME}.openDailyTemplate`, openDailyTemplate) 14 | ); 15 | 16 | context.subscriptions.push( 17 | commands.registerCommand(`${EXTENSION_NAME}.hideDailyNotes`, () => { 18 | store.dailyNotes.show = false; 19 | config.set("dailyNotes.show", false); 20 | }) 21 | ); 22 | 23 | context.subscriptions.push( 24 | commands.registerCommand( 25 | `${EXTENSION_NAME}.clearDailyNotes`, 26 | async () => { 27 | const response = await window.showInformationMessage( 28 | "Are you sure you want to clear your daily notes?", 29 | "Clear Notes" 30 | ); 31 | 32 | if (response) { 33 | clearDailyNotes(); 34 | } 35 | } 36 | ) 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/repos/wiki/hoverProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Hover, 3 | HoverProvider, 4 | languages, 5 | MarkdownString, 6 | Position, 7 | Range, 8 | TextDocument 9 | } from "vscode"; 10 | import { RepoFileSystemProvider } from "../fileSystem"; 11 | import { findLinks, getTreeItemFromLink, LINK_SELECTOR } from "./utils"; 12 | 13 | class LinkHoverProvider implements HoverProvider { 14 | public provideHover(document: TextDocument, position: Position) { 15 | const [repo] = RepoFileSystemProvider.getRepoInfo(document.uri)!; 16 | if (!repo.isWiki) { 17 | return; 18 | } 19 | 20 | const line = document.lineAt(position).text; 21 | const links = [...findLinks(line)]; 22 | if (!links) { 23 | return; 24 | } 25 | 26 | const link = links.find(({ start, end }) => { 27 | const range = new Range(position.line, start, position.line, end); 28 | return range.contains(position); 29 | }); 30 | 31 | if (!link) { 32 | return; 33 | } 34 | 35 | const treeItem = getTreeItemFromLink(repo, link.title); 36 | if (treeItem) { 37 | const contents = new MarkdownString(treeItem.contents); 38 | return new Hover(contents); 39 | } 40 | } 41 | } 42 | 43 | export function registerHoverProvider() { 44 | languages.registerHoverProvider(LINK_SELECTOR, new LinkHoverProvider()); 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/notebook.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { EXTENSION_NAME } from "../constants"; 3 | import { newGist } from "../store/actions"; 4 | import { fileNameToUri, openGistFile } from "../utils"; 5 | 6 | const NOTEBOOK_FILE = "index.ipynb"; 7 | 8 | async function newNotebook(description: string) { 9 | const files = [ 10 | { 11 | filename: NOTEBOOK_FILE 12 | } 13 | ]; 14 | 15 | const gist = await newGist(files, false, description, false); 16 | const notebookUri = fileNameToUri(gist.id, NOTEBOOK_FILE); 17 | openGistFile(notebookUri, false); 18 | } 19 | 20 | export async function registerNotebookCommands( 21 | context: vscode.ExtensionContext 22 | ) { 23 | context.subscriptions.push( 24 | vscode.commands.registerCommand( 25 | `${EXTENSION_NAME}.newNotebook`, 26 | async () => { 27 | const description = await vscode.window.showInputBox({ 28 | prompt: "Enter the description of the notebook" 29 | }); 30 | 31 | if (!description) { 32 | return; 33 | } 34 | 35 | vscode.window.withProgress( 36 | { 37 | location: vscode.ProgressLocation.Notification, 38 | title: "Creating notebook..." 39 | }, 40 | () => newNotebook(description) 41 | ); 42 | } 43 | ) 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /images/dark/diagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | drawio 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /images/light/diagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | drawio 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /images/dark/tag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /images/light/tag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /images/dark/code-snippet-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | gist-secret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /images/dark/code-tour-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | gist-secret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /images/light/code-snippet-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | gist-secret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /images/light/code-tour-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | gist-secret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /images/dark/code-swing-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | playground-secret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /images/light/code-swing-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | playground-secret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | const CONFIG_SECTION = "gistpad"; 4 | 5 | export function get(key: "comments.showThread"): string; 6 | export function get(key: "images.markdownPasteFormat"): "markdown" | "html"; 7 | export function get(key: "images.pasteType"): "file" | "base64"; 8 | export function get(key: "images.directoryName"): string; 9 | export function get(key: "mcp.enabled"): boolean; 10 | export function get(key: "mcp.markdownOnly"): boolean; 11 | export function get(key: "mcp.resources.includeArchived"): boolean; 12 | export function get(key: "mcp.resources.includeStarred"): boolean; 13 | export function get(key: "output"): boolean; 14 | export function get(key: "dailyNotes.directoryFormat"): string; 15 | export function get(key: "dailyNotes.fileExtension"): string; 16 | export function get(key: "dailyNotes.fileFormat"): string; 17 | export function get(key: "dailyNotes.show"): boolean; 18 | export function get(key: "showcaseUrl"): string; 19 | export function get(key: "syncOnSave"): boolean; 20 | export function get(key: "autoSave"): "off" | "afterDelay" | "onFocusChange"; 21 | export function get(key: "autoSaveDelay"): number; 22 | export function get(key: "treeIcons"): boolean; 23 | export function get(key: "cloneDirectory"): "gistId" | "description" | "prompt"; 24 | 25 | export function get(key: any) { 26 | const extensionConfig = vscode.workspace.getConfiguration(CONFIG_SECTION); 27 | return extensionConfig.get(key); 28 | } 29 | 30 | export async function set(key: string, value: any) { 31 | const extensionConfig = vscode.workspace.getConfiguration(CONFIG_SECTION); 32 | return extensionConfig.update(key, value, true); 33 | } 34 | -------------------------------------------------------------------------------- /images/dark/flash-code.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | flash-cards 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /images/light/flash-code.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | flash-cards 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /images/dark/code-swing-template-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | playground-template-secret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /images/light/code-swing-template-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | playground-template-secret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /images/dark/code-swing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | playground 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /images/light/code-swing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | playground 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /images/dark/code-swing-template.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | playground-template 5 | Created with Sketch. 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /images/light/code-swing-template.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | playground-template 5 | Created with Sketch. 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /images/dark/note-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | pencil-gray 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /images/light/note-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | blog-post-secret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/showcase/index.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { reaction, runInAction } from "mobx"; 3 | import { commands, env, ExtensionContext, Uri } from "vscode"; 4 | import * as config from "../config"; 5 | import { EXTENSION_NAME } from "../constants"; 6 | import { store as authStore } from "../store"; 7 | import { getGists } from "../store/actions"; 8 | import { updateGistTags } from "../utils"; 9 | import { GistShowcaseCategory, store } from "./store"; 10 | import { registerTreeProvider } from "./tree"; 11 | 12 | export async function refreshShowcase() { 13 | store.showcase.isLoading = true; 14 | 15 | return runInAction(async () => { 16 | const showcaseUrl = await config.get("showcaseUrl"); 17 | const showcase = await axios.get(showcaseUrl); 18 | store.showcase.categories = showcase.data.categories.map( 19 | (category: GistShowcaseCategory) => ({ 20 | title: category.title, 21 | gists: [], 22 | _gists: category.gists, 23 | isLoading: true 24 | }) 25 | ); 26 | 27 | store.showcase.isLoading = false; 28 | return Promise.all( 29 | store.showcase.categories.map(async (category) => { 30 | // @ts-ignore 31 | category.gists = updateGistTags(await getGists(category._gists)); 32 | category.isLoading = false; 33 | }) 34 | ); 35 | }); 36 | } 37 | 38 | export function registerShowcaseModule(context: ExtensionContext) { 39 | context.subscriptions.push( 40 | commands.registerCommand( 41 | `${EXTENSION_NAME}.refreshShowcase`, 42 | refreshShowcase 43 | ) 44 | ); 45 | 46 | context.subscriptions.push( 47 | commands.registerCommand(`${EXTENSION_NAME}.submitShowcaseEntry`, () => { 48 | env.openExternal(Uri.parse("https://aka.ms/gistpad-showcase-submission")); 49 | }) 50 | ); 51 | 52 | registerTreeProvider(context); 53 | 54 | reaction( 55 | () => authStore.isSignedIn, 56 | (isSignedIn) => { 57 | if (isSignedIn) { 58 | refreshShowcase(); 59 | } 60 | } 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/mcp.ts: -------------------------------------------------------------------------------- 1 | import { reaction } from "mobx"; 2 | import { 3 | EventEmitter, 4 | ExtensionContext, 5 | lm, 6 | // @ts-ignore 7 | McpStdioServerDefinition, 8 | workspace 9 | } from "vscode"; 10 | import * as config from "./config"; 11 | import { store } from "./store"; 12 | 13 | const onDidChange = new EventEmitter(); 14 | 15 | export function reloadMcpServer() { 16 | onDidChange.fire(); 17 | } 18 | 19 | export function registerMcpServerDefinitionProvider(context: ExtensionContext) { 20 | if (!lm.registerMcpServerDefinitionProvider) return; 21 | 22 | // When the user signs in or out, let VS Code 23 | // know that it needs to refresh the MCP configuration. 24 | reaction(() => store.isSignedIn, reloadMcpServer); 25 | 26 | // When the user updates any of the GistPad MCP config 27 | // settings, let VS Code know that it needs to refresh. 28 | context.subscriptions.push( 29 | workspace.onDidChangeConfiguration((e) => { 30 | if (e.affectsConfiguration("gistpad.mcp")) { 31 | reloadMcpServer(); 32 | } 33 | }) 34 | ); 35 | 36 | context.subscriptions.push( 37 | lm.registerMcpServerDefinitionProvider("gistpad", { 38 | onDidChangeMcpServerDefinitions: onDidChange.event, 39 | provideMcpServerDefinitions() { 40 | if (!store.isSignedIn || !config.get("mcp.enabled")) return []; 41 | 42 | const args = ["-y", "gistpad-mcp"]; 43 | 44 | if (config.get("mcp.markdownOnly") === true) args.push("--markdown"); 45 | 46 | if (config.get("mcp.resources.includeStarred") === true) 47 | args.push("--starred"); 48 | 49 | if (config.get("mcp.resources.includeArchived") === true) 50 | args.push("--archived"); 51 | 52 | return [ 53 | new McpStdioServerDefinition("GistPad", "npx", args, { 54 | GITHUB_TOKEN: store.token! 55 | }) 56 | ]; 57 | } 58 | }) 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /images/light/diagram-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | drawio-secret 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to GistPad Project 3 | 4 | Thank you for your interest in contributing to GistPad! 5 | 6 | The goal of this documentation is to provide a high-level overview of how you can build and debug the project. 7 | 8 | 9 | # Building, Debugging and Sildeloading the extension in Visual Studio Code: 10 | 11 | ## Building and Debugging the extension 12 | 13 | Ensure you have [node](https://nodejs.org/en/) installed. 14 | Clone the repo, run `npm install` and open a development instance of Code. 15 | 16 | ```bash 17 | git clone https://github.com/vsls-contrib/gistpad.git 18 | cd gistpad 19 | npm install 20 | code . 21 | ``` 22 | 23 | You can now go to the Debug viewlet (`Ctrl+Shift+D`) and select `Run Extension (watch)` then hit run (`F5`). 24 | 25 | This will open a new VS Code window which will have the title `[Extension Development Host]`. In this window, open the GistPad tab from the sidebar. 26 | 27 | In the original VS Code window, you can now add breakpoints which will be hit when you use any of the the plugin's features in the second window. 28 | 29 | ## Sideloading the extension 30 | After making changes to the extension, you might want to test it end to end instead of running it in debug mode. To do this, you can sideload the extension. This can be done by preparing the extension and loading it directly. 31 | 32 | 1. `npm install -g vsce` to make sure you have vsce installed globally 33 | 2. `git clone https://github.com/vsls-contrib/gistpad.git` to clone the repo if you haven't already done so 34 | 3. `cd gistpad` 35 | 4. `npm install` to install dependencies if you haven't already done so 36 | 5. `vsce package` to build the package. This will generate a file with extension `vsix` 37 | 6. Run the command `Extensions: Install from VSIX...`, choose the vsix file generated in the previous step 38 | 39 | For more information on using GistPad extension, follow the instructions for [getting started](https://github.com/vsls-contrib/gistpad#getting-started). 40 | 41 | ## Attribution 42 | 43 | This documentation is adapted from the Microsoft / vscode-go, available at 44 | https://github.com/Microsoft/vscode-go/wiki/Building,-Debugging-and-Sideloading-the-extension-in-Visual-Studio-Code. 45 | -------------------------------------------------------------------------------- /images/dark/diagram-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | drawio-secret 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { workspace } from "vscode"; 3 | import { registerAutoSaveManager } from "./autoSave"; 4 | import { registerCommands } from "./commands"; 5 | import { registerCommentController } from "./comments"; 6 | import * as config from "./config"; 7 | import { registerFileSystemProvider } from "./fileSystem"; 8 | import { registerMcpServerDefinitionProvider } from "./mcp"; 9 | import { Output } from "./output"; 10 | import { registerRepoModule } from "./repos"; 11 | import { extendMarkdownIt } from "./repos/wiki/markdownPreview"; 12 | import { registerShowcaseModule } from "./showcase"; 13 | import { store } from "./store"; 14 | import { initializeAuth } from "./store/auth"; 15 | import { initializeStorage } from "./store/storage"; 16 | import { registerCodeSwingModule } from "./swings"; 17 | import { registerTreeProvider } from "./tree"; 18 | import { registerProtocolHandler } from "./uriHandler"; 19 | 20 | export let output: Output; 21 | 22 | export async function activate(context: vscode.ExtensionContext) { 23 | registerCommands(context); 24 | registerProtocolHandler(); 25 | registerFileSystemProvider(store); 26 | registerTreeProvider(store, context); 27 | registerCommentController(); 28 | 29 | initializeStorage(context); 30 | initializeAuth(); 31 | 32 | registerRepoModule(context); 33 | registerCodeSwingModule(context); 34 | registerShowcaseModule(context); 35 | 36 | registerAutoSaveManager(context); 37 | 38 | const keysForSync = ["followedUsers", "repos"].map((key) => `gistpad.${key}`); 39 | if (config.get("output")) { 40 | output = new Output(); 41 | } 42 | 43 | output?.appendLine( 44 | `Setting keysForSync = ${keysForSync}`, 45 | output.messageType.Info 46 | ); 47 | 48 | context.subscriptions.push( 49 | workspace.onDidChangeConfiguration((e) => { 50 | if (e.affectsConfiguration("gistpad.output")) { 51 | if (config.get("output")) { 52 | output = new Output(); 53 | } else { 54 | output.dispose(); 55 | } 56 | } 57 | }) 58 | ); 59 | 60 | context.globalState.setKeysForSync(keysForSync); 61 | 62 | registerMcpServerDefinitionProvider(context); 63 | 64 | return { 65 | extendMarkdownIt 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /images/dark/sort-time.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 7 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /images/light/sort-time.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 7 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/fileSystem/api.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { runInAction } from "mobx"; 3 | import { ZERO_WIDTH_SPACE } from "../constants"; 4 | import { Gist, GistFile, store } from "../store"; 5 | import { getApi } from "../store/actions"; 6 | 7 | const isBinaryPath = require("is-binary-path"); 8 | export async function getFileContents(file: GistFile) { 9 | if (file.truncated || !file.content) { 10 | const responseType = isBinaryPath(file.filename!) ? "arraybuffer" : "text"; 11 | try { 12 | const { data } = await axios.get(file.raw_url!, { 13 | responseType, 14 | transformResponse: (data) => { 15 | return data; 16 | } 17 | }); 18 | file.content = data; 19 | } catch (error: any) { 20 | console.error(`Error fetching file content: ${(error as Error).message}`); 21 | // Fallback: try to fetch content directly from raw_url 22 | try { 23 | const response = await fetch(file.raw_url!); 24 | if (!response.ok) { 25 | throw new Error(`HTTP error! status: ${response.status}`); 26 | } 27 | file.content = await response.text(); 28 | } catch (fallbackError: any) { 29 | console.error(`Fallback fetch failed: ${(fallbackError as Error).message}`); 30 | throw new Error(`Failed to fetch file content: ${fallbackError.message}`); 31 | } 32 | } 33 | } 34 | 35 | return file.content!; 36 | } 37 | 38 | export async function updateGistFiles( 39 | id: string, 40 | gistFiles: Array<[string, GistFile | null]> 41 | ): Promise { 42 | const api = await getApi(); 43 | 44 | const files = gistFiles.reduce((accumulator, [filename, file]) => { 45 | if (file && file.content === "") { 46 | file.content = ZERO_WIDTH_SPACE; 47 | } 48 | 49 | return { 50 | ...accumulator, 51 | [filename]: file 52 | }; 53 | }, {}); 54 | 55 | const { body } = await api.edit(id, { files }); 56 | 57 | const gist = 58 | store.dailyNotes.gist && store.dailyNotes.gist.id === id 59 | ? store.dailyNotes.gist 60 | : (store.gists.find((gist) => gist.id === id) || 61 | store.archivedGists.find((gist) => gist.id === id))!; 62 | 63 | runInAction(() => { 64 | gist.files = body.files; 65 | gist.updated_at = body.updated_at; 66 | gist.history = body.history; 67 | }); 68 | 69 | return gist; 70 | } -------------------------------------------------------------------------------- /images/icon-activity.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icon-activity 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/repos/wiki/markdownPreview.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { RepoFileSystemProvider } from "../fileSystem"; 3 | import { getTreeItemFromLink, getUriFromLink } from "./utils"; 4 | 5 | export function extendMarkdownIt(md: any) { 6 | return md 7 | .use(require("markdown-it-regex").default, { 8 | name: "gistpad-links", 9 | regex: /(? { 11 | if ( 12 | !RepoFileSystemProvider.isRepoDocument( 13 | vscode.window.activeTextEditor!.document 14 | ) 15 | ) { 16 | return; 17 | } 18 | 19 | const [repo] = RepoFileSystemProvider.getRepoInfo( 20 | vscode.window.activeTextEditor!.document.uri 21 | )!; 22 | if (!repo.isWiki) { 23 | return; 24 | } 25 | 26 | const linkUri = getUriFromLink(repo, link); 27 | const args = encodeURIComponent(JSON.stringify([linkUri])); 28 | const href = `command:vscode.open?${args}`; 29 | 30 | return `[[${link}]]`; 31 | } 32 | }) 33 | .use(require("markdown-it-regex").default, { 34 | name: "gistpad-embeds", 35 | regex: /(?:\!\[\[)([^\]]+?)(?:\]\])/, 36 | replace: (link: string) => { 37 | if ( 38 | !RepoFileSystemProvider.isRepoDocument( 39 | vscode.window.activeTextEditor!.document 40 | ) 41 | ) { 42 | return; 43 | } 44 | 45 | const [repo] = RepoFileSystemProvider.getRepoInfo( 46 | vscode.window.activeTextEditor!.document.uri 47 | )!; 48 | if (!repo.isWiki) { 49 | return; 50 | } 51 | 52 | const treeItem = getTreeItemFromLink(repo, link); 53 | if (treeItem) { 54 | const markdown = require("markdown-it")(); 55 | markdown.renderer.rules.heading_open = ( 56 | tokens: any, 57 | index: number, 58 | options: any, 59 | env: any, 60 | self: any 61 | ) => { 62 | tokens[index].attrSet("style", "text-align: center; border: 0; margin: 10px 0 5px 0"); 63 | return self.renderToken(tokens, index, options, env, self); 64 | }; 65 | 66 | const htmlContent = markdown.render(treeItem.contents); 67 | return `
68 |
69 | ${htmlContent} 70 |
71 |
`; 72 | } 73 | } 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /src/repos/wiki/linkProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CancellationToken, 3 | commands, 4 | DocumentLink, 5 | DocumentLinkProvider, 6 | languages, 7 | Range, 8 | TextDocument, 9 | Uri 10 | } from "vscode"; 11 | import { EXTENSION_NAME } from "../../constants"; 12 | import { withProgress } from "../../utils"; 13 | import { RepoFileSystemProvider } from "../fileSystem"; 14 | import { Repository } from "../store"; 15 | import { 16 | findLinks, 17 | getPageFilePath, 18 | getTreeItemFromLink, 19 | getUriFromLink, 20 | LINK_SELECTOR 21 | } from "./utils"; 22 | 23 | class WikiDocumentLink extends DocumentLink { 24 | constructor( 25 | public repo: Repository, 26 | public title: string, 27 | range: Range, 28 | target?: Uri 29 | ) { 30 | super(range, target); 31 | } 32 | } 33 | 34 | class WikiDocumentLinkProvider implements DocumentLinkProvider { 35 | public provideDocumentLinks( 36 | document: TextDocument 37 | ): WikiDocumentLink[] | undefined { 38 | const [repo] = RepoFileSystemProvider.getRepoInfo(document.uri)!; 39 | if (!repo.isWiki) { 40 | return; 41 | } 42 | 43 | const links = findLinks(document.getText()); 44 | if (!links) { 45 | return; 46 | } 47 | const documentLinks = [...links]; 48 | return documentLinks.map(({ title, contentStart, contentEnd }) => { 49 | const linkRange = new Range( 50 | document.positionAt(contentStart), 51 | document.positionAt(contentEnd) 52 | ); 53 | 54 | const treeItem = getTreeItemFromLink(repo, title); 55 | const linkUri = treeItem ? getUriFromLink(repo, title) : undefined; 56 | 57 | return new WikiDocumentLink(repo, title, linkRange, linkUri); 58 | }); 59 | } 60 | 61 | async resolveDocumentLink(link: WikiDocumentLink, token: CancellationToken) { 62 | const filePath = getPageFilePath(link.title); 63 | link.target = RepoFileSystemProvider.getFileUri(link.repo.name, filePath); 64 | 65 | const treeItem = getTreeItemFromLink(link.repo, link.title); 66 | if (!treeItem) { 67 | await withProgress("Creating page...", async () => 68 | commands.executeCommand( 69 | `${EXTENSION_NAME}._createWikiPage`, 70 | link.repo, 71 | link.title 72 | ) 73 | ); 74 | } 75 | 76 | return link; 77 | } 78 | } 79 | 80 | export function registerDocumentLinkProvider() { 81 | languages.registerDocumentLinkProvider( 82 | LINK_SELECTOR, 83 | new WikiDocumentLinkProvider() 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /images/dark/notebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | jupyter 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /images/light/notebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | jupyter 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/abstractions/node/images/pasteImage.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as config from "../../../config"; 3 | import { pasteImageAsBase64 } from "./pasteImageAsBase64"; 4 | import { pasteImageAsFile } from "./pasteImageAsFile"; 5 | import { createUploadMarkup } from "./utils/createUploadMarkup"; 6 | import { randomInt } from "./utils/randomInt"; 7 | 8 | export const DocumentLanguages = { 9 | html: "html", 10 | markdown: "markdown", 11 | pug: "jade" 12 | }; 13 | 14 | async function addUploadingMarkup( 15 | editor: vscode.TextEditor, 16 | id: string | number, 17 | isFilePaste: boolean 18 | ) { 19 | const markup = createUploadMarkup( 20 | id, 21 | isFilePaste, 22 | editor.document.languageId 23 | ); 24 | 25 | await editor.edit((edit) => { 26 | const current = editor.selection; 27 | 28 | if (current.isEmpty) { 29 | edit.insert(current.start, markup); 30 | } else { 31 | edit.replace(current, markup); 32 | } 33 | }); 34 | } 35 | 36 | async function tryToRemoveUploadingMarkup( 37 | editor: vscode.TextEditor, 38 | id: string | number, 39 | isUploadAsFile: boolean 40 | ) { 41 | try { 42 | const markup = createUploadMarkup( 43 | id, 44 | isUploadAsFile, 45 | editor.document.languageId 46 | ); 47 | 48 | editor.edit((edit) => { 49 | const { document } = editor; 50 | const text = document.getText(); 51 | 52 | const index = text.indexOf(markup); 53 | if (index === -1) { 54 | throw new Error("No upload markup is found."); 55 | } 56 | 57 | const startPos = document.positionAt(index); 58 | const endPos = document.positionAt(index + markup.length); 59 | const range = new vscode.Selection(startPos, endPos); 60 | 61 | edit.replace(range, ""); 62 | }); 63 | } catch {} 64 | } 65 | 66 | export async function pasteImageCommand(editor: vscode.TextEditor) { 67 | const imageType = config.get("images.pasteType"); 68 | const isFilePaste = imageType === "file"; 69 | 70 | const imageId = randomInt(); 71 | const addUploadingMarkupPromise = addUploadingMarkup( 72 | editor, 73 | imageId, 74 | isFilePaste 75 | ); 76 | 77 | try { 78 | if (!isFilePaste) { 79 | return await pasteImageAsBase64(editor, imageId); 80 | } 81 | return await pasteImageAsFile(editor, imageId); 82 | } catch (e) { 83 | vscode.window.showErrorMessage( 84 | "There doesn't appear to be an image on your clipboard. Copy an image and try again." 85 | ); 86 | } finally { 87 | await addUploadingMarkupPromise; 88 | await tryToRemoveUploadingMarkup(editor, imageId, isFilePaste); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/repos/wiki/actions.ts: -------------------------------------------------------------------------------- 1 | import { runInAction } from "mobx"; 2 | import { Location, Range, Uri, workspace } from "vscode"; 3 | import { byteArrayToString } from "../../utils"; 4 | import { RepoFileSystemProvider } from "../fileSystem"; 5 | import { Repository, Tree, TreeItem } from "../store"; 6 | import { getRepoFile } from "../store/actions"; 7 | import { findLinks, getTreeItemFromLink, isWiki } from "./utils"; 8 | 9 | async function getBackLinks(uri: Uri, contents: string) { 10 | const documentLinks = [...findLinks(contents)]; 11 | return Promise.all( 12 | documentLinks.map(async ({ title, contentStart, contentEnd }) => { 13 | const document = await workspace.openTextDocument(uri); 14 | const start = document.positionAt(contentStart); 15 | const end = document.positionAt(contentEnd); 16 | 17 | return { 18 | title, 19 | linePreview: document.lineAt(start).text, 20 | location: new Location(uri, new Range(start, end)) 21 | }; 22 | }) 23 | ); 24 | } 25 | 26 | export async function updateTree(repo: Repository, tree: Tree) { 27 | if (!isWiki(repo, tree)) { 28 | repo.tree = tree; 29 | return; 30 | } 31 | 32 | const markdownFiles = tree.tree.filter((treeItem) => 33 | treeItem.path.endsWith(".md") 34 | ); 35 | 36 | const documents = await Promise.all( 37 | markdownFiles.map( 38 | async (treeItem): Promise => { 39 | const contents = byteArrayToString( 40 | await getRepoFile(repo.name, treeItem.sha) 41 | ); 42 | treeItem.contents = contents; 43 | 44 | const match = contents!.match(/^(?:#+\s*)(.+)$/m); 45 | const displayName = match ? match[1].trim() : undefined; 46 | treeItem.displayName = displayName; 47 | 48 | return treeItem; 49 | } 50 | ) 51 | ); 52 | 53 | repo.tree = tree; 54 | repo.isLoading = false; 55 | 56 | for (let { path, contents } of documents) { 57 | const uri = RepoFileSystemProvider.getFileUri(repo.name, path); 58 | const links = await getBackLinks(uri, contents!); 59 | for (const link of links) { 60 | const item = getTreeItemFromLink(repo, link.title); 61 | if (item) { 62 | const entry = documents.find((doc) => doc.path === item.path)!; 63 | if (entry.backLinks) { 64 | entry.backLinks.push(link); 65 | } else { 66 | entry.backLinks = [link]; 67 | } 68 | } 69 | } 70 | } 71 | 72 | runInAction(() => { 73 | for (let { path, backLinks } of documents) { 74 | const item = repo.tree?.tree.find((item) => item.path === path); 75 | item!.backLinks = backLinks; 76 | } 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /src/repos/comments/commands.ts: -------------------------------------------------------------------------------- 1 | import { 2 | commands, 3 | CommentMode, 4 | CommentReply, 5 | ExtensionContext, 6 | MarkdownString 7 | } from "vscode"; 8 | import { RepoCommitComment } from "."; 9 | import { EXTENSION_NAME } from "../../constants"; 10 | import { getCurrentUser } from "../../store/auth"; 11 | import { RepoFileSystemProvider } from "../fileSystem"; 12 | import { store } from "../store"; 13 | import { 14 | createRepoComment, 15 | deleteRepoComment, 16 | editRepoComment 17 | } from "./actions"; 18 | 19 | function updateComments(comment: RepoCommitComment, mode: CommentMode) { 20 | comment.parent.comments = comment.parent.comments.map((c) => { 21 | if ((c as RepoCommitComment).id === comment.id) { 22 | c.mode = mode; 23 | } 24 | 25 | return c; 26 | }); 27 | } 28 | 29 | export function registerCommentCommands(context: ExtensionContext) { 30 | context.subscriptions.push( 31 | commands.registerCommand( 32 | `${EXTENSION_NAME}.addRepositoryComment`, 33 | async ({ text, thread }: CommentReply) => { 34 | const [repo, path] = RepoFileSystemProvider.getFileInfo(thread.uri)!; 35 | const repository = store.repos.find((r) => r.name === repo); 36 | 37 | const comment = await createRepoComment( 38 | repo, 39 | path, 40 | text, 41 | thread.range!.start.line + 1 42 | ); 43 | 44 | repository?.comments.push(comment); 45 | 46 | const newComment = new RepoCommitComment( 47 | comment, 48 | repo, 49 | thread, 50 | getCurrentUser() 51 | ); 52 | 53 | thread.comments = [newComment]; 54 | } 55 | ) 56 | ); 57 | 58 | context.subscriptions.push( 59 | commands.registerCommand( 60 | `${EXTENSION_NAME}.deleteRepositoryComment`, 61 | async (comment: RepoCommitComment) => { 62 | await deleteRepoComment(comment.repo, comment.id); 63 | comment.parent.dispose(); 64 | } 65 | ) 66 | ); 67 | 68 | context.subscriptions.push( 69 | commands.registerCommand( 70 | `${EXTENSION_NAME}.editRepositoryComment`, 71 | async (comment: RepoCommitComment) => 72 | updateComments(comment, CommentMode.Editing) 73 | ) 74 | ); 75 | 76 | commands.registerCommand( 77 | `${EXTENSION_NAME}.saveRepositoryComment`, 78 | async (comment: RepoCommitComment) => { 79 | const content = 80 | comment.body instanceof MarkdownString 81 | ? comment.body.value 82 | : comment.body; 83 | 84 | await editRepoComment(comment.repo, comment.id, content); 85 | 86 | updateComments(comment, CommentMode.Preview); 87 | } 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /images/dark/flash-code-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | flash-cards-secret 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/abstractions/node/images/pasteImageAsFile.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { TextEditor } from "vscode"; 3 | import * as config from "../../../config"; 4 | import { DIRECTORY_SEPARATOR, FS_SCHEME } from "../../../constants"; 5 | import { RepoFileSystemProvider } from "../../../repos/fileSystem"; 6 | import { store } from "../../../store"; 7 | import { 8 | encodeDirectoryName, 9 | fileNameToUri, 10 | getGistDetailsFromUri 11 | } from "../../../utils"; 12 | import { clipboardToImageBuffer } from "./clipboardToImageBuffer"; 13 | import { createImageMarkup } from "./utils/createImageMarkup"; 14 | import { pasteImageMarkup } from "./utils/pasteImageMarkup"; 15 | 16 | function getImageFileInfo( 17 | editor: TextEditor, 18 | fileName: string 19 | ): [vscode.Uri, string] { 20 | switch (editor.document.uri.scheme) { 21 | case FS_SCHEME: { 22 | const { gistId } = getGistDetailsFromUri(editor.document.uri); 23 | 24 | const src = `https://gist.github.com/${ 25 | store.login 26 | }/${gistId}/raw/${encodeDirectoryName(fileName)}`; 27 | 28 | return [fileNameToUri(gistId, fileName), src]; 29 | } 30 | default: { 31 | // TODO: Figure out a solution that will work for private repos 32 | const [repo] = RepoFileSystemProvider.getRepoInfo(editor.document.uri)!; 33 | const fileUri = RepoFileSystemProvider.getFileUri(repo.name, fileName); 34 | const src = `https://github.com/${repo.name}/raw/${repo.branch}/${fileName}`; 35 | return [fileUri, src]; 36 | } 37 | } 38 | } 39 | 40 | function getImageFileName() { 41 | const uploadDirectory = config.get("images.directoryName"); 42 | const prefix = uploadDirectory 43 | ? `${uploadDirectory}${DIRECTORY_SEPARATOR}` 44 | : ""; 45 | 46 | const dateSting = new Date().toDateString().replace(/\s/g, "_"); 47 | return `${prefix}${dateSting}_${Date.now()}.png`; 48 | } 49 | 50 | export async function pasteImageAsFile( 51 | editor: vscode.TextEditor, 52 | imageMarkupId: string | number 53 | ) { 54 | const fileName = getImageFileName(); 55 | const imageBits = await clipboardToImageBuffer.getImageBits(); 56 | 57 | const [uri, src] = getImageFileInfo(editor, fileName); 58 | try { 59 | await vscode.workspace.fs.writeFile(uri, imageBits); 60 | } catch (err) { 61 | // TODO: fs.writeFile gives an error which prevents pasting images from the clipboard 62 | // Error (FileSystemError): Unable to write file 'gist://Gist_ID/images/imageName.png' 63 | // (Error: [mobx] 'set()' can only be used on observable objects, arrays and maps) 64 | } 65 | 66 | const imageMarkup = await createImageMarkup(src, editor.document.languageId); 67 | 68 | await pasteImageMarkup(editor, imageMarkup, imageMarkupId); 69 | } 70 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const CopyPlugin = require("copy-webpack-plugin"); 3 | 4 | const config = { 5 | mode: "production", 6 | entry: "./src/extension.ts", 7 | externals: { 8 | vscode: "commonjs vscode" 9 | }, 10 | resolve: { 11 | extensions: [".ts", ".js", ".json"] 12 | }, 13 | node: { 14 | __filename: false, 15 | __dirname: false 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.ts$/, 21 | exclude: /node_modules/, 22 | use: [ 23 | { 24 | loader: "ts-loader" 25 | } 26 | ] 27 | } 28 | ] 29 | } 30 | }; 31 | 32 | const nodeConfig = { 33 | ...config, 34 | target: "node", 35 | output: { 36 | path: path.resolve(__dirname, "dist"), 37 | filename: "extension.js", 38 | libraryTarget: "commonjs2", 39 | devtoolModuleFilenameTemplate: "../[resource-path]" 40 | }, 41 | resolve: { 42 | ...config.resolve, 43 | alias: { 44 | "@abstractions": path.join(__dirname, "./src/abstractions/node") 45 | } 46 | }, 47 | plugins: [ 48 | new CopyPlugin([ 49 | { 50 | from: path.resolve( 51 | __dirname, 52 | "./src/abstractions/node/images/scripts/*" 53 | ), 54 | to: path.resolve(__dirname, "./dist/scripts/"), 55 | flatten: true 56 | } 57 | ]) 58 | ] 59 | }; 60 | 61 | const webConfig = { 62 | ...config, 63 | target: "webworker", 64 | output: { 65 | path: path.resolve(__dirname, "dist"), 66 | filename: "extension-web.js", 67 | libraryTarget: "commonjs2", 68 | devtoolModuleFilenameTemplate: "../[resource-path]" 69 | }, 70 | externals: { 71 | ...config.externals, 72 | "simple-git": "commonjs simple-git" 73 | }, 74 | resolve: { 75 | ...config.resolve, 76 | alias: { 77 | "@abstractions": path.join(__dirname, "./src/abstractions/browser"), 78 | "simple-git": path.resolve( 79 | __dirname, 80 | "src/abstractions/browser/simple-git" 81 | ) 82 | }, 83 | fallback: { 84 | child_process: false, 85 | crypto: false, 86 | fs: false, // TODO: Implement file uploading in the browser 87 | http: require.resolve("stream-http"), 88 | https: require.resolve("https-browserify"), 89 | os: require.resolve("os-browserify/browser"), 90 | path: require.resolve("path-browserify"), 91 | querystring: require.resolve("querystring-es3"), 92 | stream: false, 93 | url: require.resolve("url/"), 94 | util: require.resolve("util/"), 95 | zlib: false 96 | } 97 | } 98 | }; 99 | 100 | module.exports = [nodeConfig, webConfig]; 101 | -------------------------------------------------------------------------------- /images/light/flash-code-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | flash-cards-secret 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/store/storage.ts: -------------------------------------------------------------------------------- 1 | import { reaction } from "mobx"; 2 | import { commands, ExtensionContext, workspace } from "vscode"; 3 | import { GroupType, SortOrder, store } from "."; 4 | import * as config from "../config"; 5 | import { EXTENSION_NAME } from "../constants"; 6 | import { output } from "../extension"; 7 | 8 | const FOLLOW_KEY = "gistpad.followedUsers"; 9 | 10 | // TODO: Replace these with user settings 11 | const SORT_ORDER_KEY = "gistpad:sortOrder"; 12 | const GROUP_TYPE_KEY = "gistpad:groupType"; 13 | 14 | const SHOW_DAILY_NOTES_KEY = "dailyNotes.show"; 15 | 16 | export interface IStorage { 17 | followedUsers: string[]; 18 | } 19 | 20 | function updateSortOrder(context: ExtensionContext, sortOrder: SortOrder) { 21 | context.globalState.update(SORT_ORDER_KEY, sortOrder); 22 | commands.executeCommand("setContext", SORT_ORDER_KEY, sortOrder); 23 | } 24 | 25 | function updateGroupType(context: ExtensionContext, groupType: GroupType) { 26 | context.globalState.update(GROUP_TYPE_KEY, groupType); 27 | commands.executeCommand("setContext", GROUP_TYPE_KEY, groupType); 28 | } 29 | 30 | export let followedUsersStorage: IStorage; 31 | export async function initializeStorage(context: ExtensionContext) { 32 | followedUsersStorage = { 33 | get followedUsers() { 34 | let followedUsers = context.globalState.get(FOLLOW_KEY, []).sort(); 35 | output?.appendLine( 36 | `Getting followed users from global state = ${followedUsers}`, 37 | output.messageType.Info 38 | ); 39 | return followedUsers; 40 | }, 41 | set followedUsers(followedUsers: string[]) { 42 | output?.appendLine( 43 | `Setting followed users to ${followedUsers}`, 44 | output.messageType.Info 45 | ); 46 | context.globalState.update(FOLLOW_KEY, followedUsers); 47 | } 48 | }; 49 | 50 | const sortOrder = context.globalState.get( 51 | SORT_ORDER_KEY, 52 | SortOrder.updatedTime 53 | ); 54 | 55 | store.sortOrder = sortOrder; 56 | commands.executeCommand("setContext", SORT_ORDER_KEY, sortOrder); 57 | 58 | reaction( 59 | () => [store.sortOrder], 60 | () => updateSortOrder(context, store.sortOrder) 61 | ); 62 | 63 | const groupType = context.globalState.get(GROUP_TYPE_KEY, GroupType.none); 64 | store.groupType = groupType; 65 | commands.executeCommand("setContext", GROUP_TYPE_KEY, groupType); 66 | 67 | reaction( 68 | () => [store.groupType], 69 | () => updateGroupType(context, store.groupType) 70 | ); 71 | 72 | store.dailyNotes.show = await config.get(SHOW_DAILY_NOTES_KEY); 73 | 74 | workspace.onDidChangeConfiguration(async (e) => { 75 | if (e.affectsConfiguration(`${EXTENSION_NAME}.${SHOW_DAILY_NOTES_KEY}`)) { 76 | store.dailyNotes.show = await config.get(SHOW_DAILY_NOTES_KEY); 77 | } 78 | }); 79 | } 80 | -------------------------------------------------------------------------------- /src/repos/wiki/utils.ts: -------------------------------------------------------------------------------- 1 | import { DocumentSelector } from "vscode"; 2 | import { RepoFileSystemProvider, REPO_SCHEME } from "../fileSystem"; 3 | import { Repository, Tree } from "../store"; 4 | import { sanitizeName } from "../utils"; 5 | import { config } from "./config"; 6 | 7 | export const LINK_SELECTOR: DocumentSelector = [ 8 | { 9 | scheme: REPO_SCHEME, 10 | language: "markdown" 11 | } 12 | ]; 13 | 14 | export const LINK_PREFIX = "[["; 15 | export const LINK_SUFFIX = "]]"; 16 | const LINK_PATTERN = /(?:#?\[\[)(?[^\]]+)(?:\]\])|#(?[^\s]+)/gi; 17 | 18 | const WIKI_REPO_PATTERNS = ["wiki", "notes", "obsidian", "journal"]; 19 | 20 | const WIKI_WORKSPACE_FILES = [ 21 | "gistpad.json", 22 | ".vscode/gistpad.json", 23 | ".vscode/foam.json" 24 | ]; 25 | 26 | const DAILY_PATTERN = /\d{4}-\d{2}-\d{2}/; 27 | export function getPageFilePath(name: string) { 28 | let fileName = sanitizeName(name).toLocaleLowerCase(); 29 | if (!fileName.endsWith(".md")) { 30 | fileName += ".md"; 31 | } 32 | 33 | if (DAILY_PATTERN.test(fileName)) { 34 | const pathPrefix = config.dailyDirectName 35 | ? `${config.dailyDirectName}/` 36 | : ""; 37 | return `${pathPrefix}${fileName}`; 38 | } else { 39 | return fileName; 40 | } 41 | } 42 | 43 | export interface WikiLink { 44 | title: string; 45 | start: number; 46 | end: number; 47 | contentStart: number; 48 | contentEnd: number; 49 | } 50 | 51 | export function* findLinks(contents: string): Generator { 52 | let match; 53 | while ((match = LINK_PATTERN.exec(contents))) { 54 | const title = match.groups!.page || match.groups!.tag; 55 | const start = match.index; 56 | const end = start + match[0].length; 57 | const contentStart = start + match[0].indexOf(title); 58 | const contentEnd = contentStart + title.length; 59 | 60 | yield { 61 | title, 62 | start, 63 | end, 64 | contentStart, 65 | contentEnd 66 | }; 67 | } 68 | } 69 | 70 | export function getTreeItemFromLink(repo: Repository, link: string) { 71 | return repo.tree!.tree.find( 72 | (item) => 73 | item.displayName?.toLocaleLowerCase() === link.toLocaleLowerCase() || 74 | item.path === link || 75 | item.path.replace(".md", "") === link 76 | ); 77 | } 78 | 79 | export function getUriFromLink(repo: Repository, link: string) { 80 | const treeItem = getTreeItemFromLink(repo, link); 81 | return RepoFileSystemProvider.getFileUri(repo.name, treeItem?.path); 82 | } 83 | 84 | export function isWiki(repo: Repository, tree?: Tree) { 85 | const repoTree = tree || repo.tree; 86 | return ( 87 | WIKI_REPO_PATTERNS.some((pattern) => 88 | repo.name.toLocaleLowerCase().includes(pattern) 89 | ) || 90 | !!repoTree?.tree.some((item) => WIKI_WORKSPACE_FILES.includes(item.path)) 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /images/dark/code-swing-tutorial.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | hat 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /images/light/code-swing-tutorial.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | hat 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/repos/tree/nodes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ThemeIcon, 3 | TreeItem, 4 | TreeItemCollapsibleState, 5 | Uri 6 | } from "vscode"; 7 | import { RepoFileSystemProvider } from "../fileSystem"; 8 | import { Repository, RepositoryFile, store, TreeItemBackLink } from "../store"; 9 | 10 | export class RepositoryNode extends TreeItem { 11 | constructor(public repo: Repository) { 12 | super(repo.name, TreeItemCollapsibleState.Expanded); 13 | 14 | const iconName = repo.isWiki ? "book" : "repo"; 15 | this.iconPath = new ThemeIcon(iconName); 16 | 17 | this.contextValue = 18 | "gistpad." + (repo.isSwing ? "swing" : repo.isWiki ? "wiki" : "repo"); 19 | 20 | if (repo.isWiki && store.wiki?.name === repo.name) { 21 | this.description = "Primary"; 22 | } 23 | 24 | if (repo.branch !== repo.defaultBranch) { 25 | this.contextValue += ".branch"; 26 | this.description = repo.branch; 27 | } 28 | 29 | if (repo.hasTours) { 30 | this.contextValue += ".hasTours"; 31 | } 32 | 33 | this.tooltip = `Repo: ${repo.name} 34 | Branch: ${repo.branch}`; 35 | } 36 | } 37 | 38 | export class RepositoryFileNode extends TreeItem { 39 | constructor(public repo: Repository, public file: RepositoryFile) { 40 | super( 41 | file.name, 42 | file.isDirectory || file.backLinks 43 | ? TreeItemCollapsibleState.Collapsed 44 | : TreeItemCollapsibleState.None 45 | ); 46 | 47 | this.iconPath = file.isDirectory ? ThemeIcon.Folder : ThemeIcon.File; 48 | this.resourceUri = file.uri; 49 | 50 | if (!file.isDirectory) { 51 | this.command = { 52 | command: "vscode.open", 53 | title: "Open file", 54 | arguments: [file.uri] 55 | }; 56 | } 57 | 58 | if (repo.isWiki && file.backLinks) { 59 | this.description = file.backLinks.length.toString(); 60 | } else if (file.isDirectory) { 61 | this.description = file.files!.length.toString(); 62 | } 63 | 64 | const repoType = repo.isWiki ? "wiki" : "repo"; 65 | this.contextValue = file.isDirectory 66 | ? `gistpad.${repoType}Directory` 67 | : "gistpad.repoFile"; 68 | } 69 | } 70 | 71 | function getbackLinkDisplayName(uri: Uri) { 72 | const [, file] = RepoFileSystemProvider.getRepoInfo(uri)!; 73 | return file?.displayName || file?.path || ""; 74 | } 75 | 76 | export class RepositoryFileBackLinkNode extends TreeItem { 77 | constructor(repo: string, public backLink: TreeItemBackLink) { 78 | super( 79 | getbackLinkDisplayName(backLink.location.uri), 80 | TreeItemCollapsibleState.None 81 | ); 82 | 83 | this.description = backLink.linePreview; 84 | this.tooltip = backLink.linePreview; 85 | 86 | this.command = { 87 | command: "vscode.open", 88 | arguments: [ 89 | backLink.location.uri, 90 | { selection: backLink.location.range } 91 | ], 92 | title: "Open File" 93 | }; 94 | 95 | this.contextValue = "gistpad.repoFile.backLink"; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/showcase/tree.ts: -------------------------------------------------------------------------------- 1 | import { reaction } from "mobx"; 2 | import { 3 | Disposable, 4 | Event, 5 | EventEmitter, 6 | ExtensionContext, 7 | ProviderResult, 8 | TreeDataProvider, 9 | TreeItem, 10 | TreeItemCollapsibleState, 11 | window 12 | } from "vscode"; 13 | import { EXTENSION_NAME } from "../constants"; 14 | import { getGistFiles } from "../tree"; 15 | import { 16 | FollowedUserGistNode, 17 | GistDirectoryNode, 18 | GistNode, 19 | LoadingNode, 20 | TreeNode 21 | } from "../tree/nodes"; 22 | import { isOwnedGist } from "../utils"; 23 | import { GistShowcaseCategory, store } from "./store"; 24 | 25 | export class GistShowcaseCategoryNode extends TreeNode { 26 | constructor(public category: GistShowcaseCategory) { 27 | super(category.title, TreeItemCollapsibleState.Expanded); 28 | this.contextValue = "showcase.category"; 29 | } 30 | } 31 | 32 | class ShowcaseTreeProvider implements TreeDataProvider, Disposable { 33 | private _disposables: Disposable[] = []; 34 | 35 | private _onDidChangeTreeData = new EventEmitter(); 36 | public readonly onDidChangeTreeData: Event = this._onDidChangeTreeData 37 | .event; 38 | 39 | constructor(private extensionContext: ExtensionContext) { 40 | reaction( 41 | () => [ 42 | store.showcase.isLoading, 43 | store.showcase?.categories.map((category) => [category.isLoading]) 44 | ], 45 | () => { 46 | this._onDidChangeTreeData.fire(); 47 | } 48 | ); 49 | } 50 | 51 | getTreeItem(node: TreeNode): TreeItem { 52 | return node; 53 | } 54 | 55 | getChildren(element?: TreeNode): ProviderResult { 56 | if (!element) { 57 | if (store.showcase.isLoading) { 58 | return [new TreeNode("Loading showcase...")]; 59 | } 60 | 61 | return store.showcase?.categories.map( 62 | (category) => new GistShowcaseCategoryNode(category) 63 | ); 64 | } else if (element instanceof GistShowcaseCategoryNode) { 65 | if (element.category.isLoading) { 66 | return [new LoadingNode()]; 67 | } 68 | 69 | return element.category.gists.map((gist) => { 70 | const owned = isOwnedGist(gist.id); 71 | 72 | return owned 73 | ? new GistNode(gist, this.extensionContext) 74 | : new FollowedUserGistNode(gist, this.extensionContext); 75 | }); 76 | } else if (element instanceof GistNode) { 77 | return getGistFiles(element.gist); 78 | } else if (element instanceof GistDirectoryNode) { 79 | return getGistFiles(element.gist, element.directory); 80 | } 81 | } 82 | 83 | dispose() { 84 | this._disposables.forEach((disposable) => disposable.dispose()); 85 | } 86 | } 87 | 88 | export function registerTreeProvider(extensionContext: ExtensionContext) { 89 | window.createTreeView(`${EXTENSION_NAME}.showcase`, { 90 | showCollapseAll: true, 91 | treeDataProvider: new ShowcaseTreeProvider(extensionContext), 92 | canSelectMany: true 93 | }); 94 | } 95 | -------------------------------------------------------------------------------- /src/repos/wiki/completionProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CompletionItem, 3 | CompletionItemKind, 4 | CompletionItemProvider, 5 | languages, 6 | Position, 7 | Range, 8 | TextDocument 9 | } from "vscode"; 10 | import { EXTENSION_NAME } from "../../constants"; 11 | import { RepoFileSystemProvider } from "../fileSystem"; 12 | import { 13 | getTreeItemFromLink, 14 | LINK_PREFIX, 15 | LINK_SELECTOR, 16 | LINK_SUFFIX 17 | } from "./utils"; 18 | 19 | class WikiLinkCompletionProvider implements CompletionItemProvider { 20 | provideCompletionItems( 21 | document: TextDocument, 22 | position: Position 23 | ): CompletionItem[] | undefined { 24 | const [repo, file] = RepoFileSystemProvider.getRepoInfo(document.uri)!; 25 | if (!repo.isWiki) { 26 | return; 27 | } 28 | 29 | const lineText = document 30 | .lineAt(position) 31 | .text.substr(0, position.character); 32 | 33 | const linkOpening = lineText.lastIndexOf(LINK_PREFIX); 34 | if (linkOpening === -1) { 35 | return; 36 | } 37 | 38 | const link = lineText.substr(linkOpening + LINK_PREFIX.length); 39 | if (link === undefined || link.includes(LINK_SUFFIX)) { 40 | return; 41 | } 42 | 43 | const documents = repo.documents.filter((doc) => doc.path !== file?.path); 44 | const documentItems = documents.map((doc) => { 45 | const item = new CompletionItem( 46 | doc.displayName || doc.path, 47 | CompletionItemKind.File 48 | ); 49 | 50 | // Automatically save the document upon selection 51 | // in order to update the backlinks in the tree. 52 | item.command = { 53 | command: "workbench.action.files.save", 54 | title: "Reference document" 55 | }; 56 | 57 | return item; 58 | }); 59 | 60 | if (!getTreeItemFromLink(repo, link)) { 61 | const newDocumentItem = new CompletionItem(link, CompletionItemKind.File); 62 | newDocumentItem.detail = `Create new page page "${link}"`; 63 | 64 | // Since we're dynamically updating the range as the user types, 65 | // we need to ensure the range spans the enter document name. 66 | newDocumentItem.range = new Range( 67 | position.translate({ characterDelta: -link.length }), 68 | position 69 | ); 70 | 71 | // As soon as the user accepts this item, 72 | // automatically create the new document. 73 | newDocumentItem.command = { 74 | command: `${EXTENSION_NAME}._createWikiPage`, 75 | title: "Create new page", 76 | arguments: [repo, link] 77 | }; 78 | 79 | documentItems.unshift(newDocumentItem); 80 | } 81 | 82 | return documentItems; 83 | } 84 | } 85 | 86 | let triggerCharacters = [...Array(94).keys()].map((i) => 87 | String.fromCharCode(i + 32) 88 | ); 89 | 90 | export async function registerLinkCompletionProvider() { 91 | languages.registerCompletionItemProvider( 92 | LINK_SELECTOR, 93 | new WikiLinkCompletionProvider(), 94 | ...triggerCharacters 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/tour.ts: -------------------------------------------------------------------------------- 1 | import { Event, extensions, Uri } from "vscode"; 2 | import { getFileContents } from "./fileSystem/api"; 3 | import { GistFile } from "./store"; 4 | 5 | export const TOUR_FILE = "main.tour"; 6 | 7 | interface CodeTourApi { 8 | startTour( 9 | tour: any, 10 | stepNumber: number, 11 | workspaceRoot: Uri, 12 | startInEditMode: boolean, 13 | canEdit: boolean 14 | ): void; 15 | 16 | endCurrentTour(): void; 17 | exportTour(tour: any): string; 18 | recordTour(workspaceRoot: Uri): void; 19 | 20 | promptForTour(workspaceRoot: Uri, tours: any[]): Promise; 21 | selectTour(tours: any[], workspaceRoot: Uri): Promise; 22 | 23 | onDidEndTour: Event; 24 | } 25 | 26 | let codeTourApi: CodeTourApi; 27 | export async function ensureApi() { 28 | if (!codeTourApi) { 29 | const codeTour = extensions.getExtension("vsls-contrib.codetour"); 30 | if (!codeTour) { 31 | return; 32 | } 33 | if (!codeTour.isActive) { 34 | await codeTour.activate(); 35 | } 36 | 37 | codeTourApi = codeTour.exports; 38 | } 39 | } 40 | 41 | export async function isCodeTourInstalled() { 42 | await ensureApi(); 43 | return !!codeTourApi; 44 | } 45 | 46 | export async function startTour( 47 | tour: any, 48 | workspaceRoot: Uri, 49 | startInEditMode: boolean = false, 50 | canEdit: boolean = true 51 | ) { 52 | await ensureApi(); 53 | 54 | tour.id = `${workspaceRoot.toString()}/${TOUR_FILE}`; 55 | codeTourApi.startTour(tour, 0, workspaceRoot, startInEditMode, canEdit); 56 | } 57 | 58 | export async function startTourFromFile( 59 | tourFile: GistFile, 60 | workspaceRoot: Uri, 61 | startInEditMode: boolean = false, 62 | canEdit: boolean = true 63 | ) { 64 | await ensureApi(); 65 | 66 | const tourContent = await getFileContents(tourFile); 67 | if (!tourContent) { 68 | return; 69 | } 70 | 71 | try { 72 | const tour = JSON.parse(tourContent); 73 | startTour(tour, workspaceRoot, startInEditMode, canEdit); 74 | } catch (e) {} 75 | } 76 | 77 | export async function endCurrentTour() { 78 | await ensureApi(); 79 | codeTourApi.endCurrentTour(); 80 | } 81 | 82 | export async function exportTour(tour: any) { 83 | await ensureApi(); 84 | return codeTourApi.exportTour(tour); 85 | } 86 | 87 | export async function recordTour(workspaceRoot: Uri) { 88 | await ensureApi(); 89 | return codeTourApi.recordTour(workspaceRoot); 90 | } 91 | 92 | export async function promptForTour(workspaceRoot: Uri, tours: any[]) { 93 | await ensureApi(); 94 | return codeTourApi.promptForTour(workspaceRoot, tours); 95 | } 96 | 97 | export async function onDidEndTour(listener: (tour: any) => void) { 98 | await ensureApi(); 99 | return codeTourApi.onDidEndTour(listener); 100 | } 101 | 102 | export async function selectTour(tours: any[], workspaceRoot: Uri) { 103 | await ensureApi(); 104 | return codeTourApi.selectTour(tours, workspaceRoot); 105 | } 106 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '31 21 * * 6' 22 | workflow_dispatch: 23 | 24 | jobs: 25 | analyze: 26 | name: Analyze 27 | runs-on: ubuntu-latest 28 | permissions: 29 | actions: read 30 | contents: read 31 | security-events: write 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | language: [ 'javascript' ] 37 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 38 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v3 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v2 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | 53 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 54 | queries: security-extended,security-and-quality 55 | 56 | 57 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 58 | # If this step fails, then you should remove it and run the build manually (see below) 59 | - name: Autobuild 60 | uses: github/codeql-action/autobuild@v2 61 | 62 | # ℹ️ Command-line programs to run using the OS shell. 63 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 64 | 65 | # If the Autobuild fails above, remove it and uncomment the following three lines. 66 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 67 | 68 | # - run: | 69 | # echo "Run, Build Application using script" 70 | # ./location_of_script_within_repo/buildscript.sh 71 | 72 | - name: Perform CodeQL Analysis 73 | uses: github/codeql-action/analyze@v2 74 | with: 75 | category: "/language:${{matrix.language}}" 76 | -------------------------------------------------------------------------------- /src/commands/comments.ts: -------------------------------------------------------------------------------- 1 | import { 2 | commands, 3 | CommentMode, 4 | CommentReply, 5 | ExtensionContext, 6 | MarkdownString 7 | } from "vscode"; 8 | import { GistCodeComment } from "../comments"; 9 | import { EXTENSION_NAME } from "../constants"; 10 | import { 11 | createGistComment, 12 | deleteGistComment, 13 | editGistComment 14 | } from "../store/actions"; 15 | import { getCurrentUser } from "../store/auth"; 16 | import { getGistDetailsFromUri } from "../utils"; 17 | 18 | async function addComment(reply: CommentReply) { 19 | let thread = reply.thread; 20 | 21 | const { gistId } = getGistDetailsFromUri(thread.uri); 22 | const comment = await createGistComment(gistId, reply.text); 23 | 24 | let newComment = new GistCodeComment( 25 | comment, 26 | gistId, 27 | thread, 28 | getCurrentUser() 29 | ); 30 | 31 | thread.comments = [...thread.comments, newComment]; 32 | } 33 | 34 | export function registerCommentCommands(context: ExtensionContext) { 35 | context.subscriptions.push( 36 | commands.registerCommand(`${EXTENSION_NAME}.addGistComment`, addComment) 37 | ); 38 | 39 | context.subscriptions.push( 40 | commands.registerCommand(`${EXTENSION_NAME}.replyGistComment`, addComment) 41 | ); 42 | 43 | context.subscriptions.push( 44 | commands.registerCommand( 45 | `${EXTENSION_NAME}.editGistComment`, 46 | async (comment: GistCodeComment) => { 47 | if (!comment.parent) { 48 | return; 49 | } 50 | 51 | comment.parent.comments = comment.parent.comments.map((cmt) => { 52 | if ((cmt as GistCodeComment).id === comment.id) { 53 | cmt.mode = CommentMode.Editing; 54 | } 55 | 56 | return cmt; 57 | }); 58 | } 59 | ) 60 | ); 61 | 62 | commands.registerCommand( 63 | `${EXTENSION_NAME}.saveGistComment`, 64 | async (comment: GistCodeComment) => { 65 | if (!comment.parent) { 66 | return; 67 | } 68 | 69 | const content = 70 | comment.body instanceof MarkdownString 71 | ? comment.body.value 72 | : comment.body; 73 | 74 | await editGistComment(comment.gistId, comment.id, content); 75 | 76 | comment.parent.comments = comment.parent.comments.map((cmt) => { 77 | if ((cmt as GistCodeComment).id === comment.id) { 78 | cmt.mode = CommentMode.Preview; 79 | } 80 | 81 | return cmt; 82 | }); 83 | } 84 | ); 85 | 86 | context.subscriptions.push( 87 | commands.registerCommand( 88 | `${EXTENSION_NAME}.deleteGistComment`, 89 | async (comment: GistCodeComment) => { 90 | let thread = comment.parent; 91 | if (!thread) { 92 | return; 93 | } 94 | 95 | await deleteGistComment(comment.gistId, comment.id); 96 | thread.comments = thread.comments.filter( 97 | (cmt) => (cmt as GistCodeComment).id !== comment.id 98 | ); 99 | 100 | if (thread.comments.length === 0) { 101 | thread.dispose(); 102 | } 103 | } 104 | ) 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /images/dark/notebook-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | jupyter-secret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /images/light/notebook-secret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | jupyter-secret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /images/dark/sort-alphabetical.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 6 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /images/light/sort-alphabetical.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 6 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /images/daily.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/repos/wiki/comments.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Comment, 3 | CommentAuthorInformation, 4 | CommentController, 5 | CommentMode, 6 | comments, 7 | CommentThreadCollapsibleState, 8 | MarkdownString, 9 | Range, 10 | Uri, 11 | window, 12 | workspace 13 | } from "vscode"; 14 | import { RepoFileSystemProvider } from "../fileSystem"; 15 | import { TreeItemBackLink } from "../store"; 16 | 17 | export class WikiBacklinksComments implements Comment { 18 | public body: string | MarkdownString; 19 | public mode: CommentMode = CommentMode.Preview; 20 | public author: CommentAuthorInformation; 21 | 22 | constructor(backlinks: TreeItemBackLink[]) { 23 | const content = backlinks 24 | .map((link) => { 25 | const [, file] = RepoFileSystemProvider.getRepoInfo(link.location.uri)!; 26 | const title = file!.displayName || file!.path; 27 | const args = [ 28 | link.location.uri, 29 | { 30 | selection: { 31 | start: { 32 | line: link.location.range.start.line, 33 | character: link.location.range.start.character 34 | }, 35 | end: { 36 | line: link.location.range.end.line, 37 | character: link.location.range.end.character 38 | } 39 | } 40 | } 41 | ]; 42 | const command = `command:vscode.open?${encodeURIComponent( 43 | JSON.stringify(args) 44 | )}`; 45 | return `### [${title}](${command} 'Open the "${title}" page') 46 | 47 | \`\`\`markdown 48 | ${link.linePreview} 49 | \`\`\``; 50 | }) 51 | .join("\r\n"); 52 | 53 | const markdown = new MarkdownString(content); 54 | markdown.isTrusted = true; 55 | 56 | this.body = markdown; 57 | 58 | this.author = { 59 | name: "GistPad (Backlinks)", 60 | iconPath: Uri.parse( 61 | "https://cdn.jsdelivr.net/gh/vsls-contrib/gistpad/images/icon.png" 62 | ) 63 | }; 64 | } 65 | } 66 | 67 | let controller: CommentController | undefined; 68 | export function registerCommentController() { 69 | window.onDidChangeActiveTextEditor((e) => { 70 | if (controller) { 71 | controller.dispose(); 72 | controller = undefined; 73 | } 74 | 75 | if (!e || !RepoFileSystemProvider.isRepoDocument(e.document)) { 76 | return; 77 | } 78 | 79 | const info = RepoFileSystemProvider.getRepoInfo(e.document.uri)!; 80 | if (!info || !info[0].isWiki || !info[1]?.backLinks) { 81 | return; 82 | } 83 | 84 | controller = comments.createCommentController("gistpad.wiki", "Backlinks"); 85 | const comment = new WikiBacklinksComments(info[1].backLinks); 86 | const thread = controller.createCommentThread( 87 | e.document.uri, 88 | new Range(e.document.lineCount, 0, e.document.lineCount, 0), 89 | [comment] 90 | ); 91 | // @ts-ignore 92 | thread.canReply = false; 93 | thread.collapsibleState = CommentThreadCollapsibleState.Expanded; 94 | 95 | workspace.onDidChangeTextDocument((change) => { 96 | if (change.document.uri.toString() === e.document.uri.toString()) { 97 | thread.range = new Range( 98 | e.document.lineCount, 99 | 0, 100 | e.document.lineCount, 101 | 0 102 | ); 103 | } 104 | }); 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /src/comments/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Comment, 3 | CommentAuthorInformation, 4 | CommentMode, 5 | comments, 6 | CommentThread, 7 | CommentThreadCollapsibleState, 8 | MarkdownString, 9 | Range, 10 | TextDocument, 11 | Uri, 12 | workspace 13 | } from "vscode"; 14 | import * as config from "../config"; 15 | import { EXTENSION_NAME } from "../constants"; 16 | import { GistComment } from "../store"; 17 | import { getGistComments } from "../store/actions"; 18 | import { getCurrentUser } from "../store/auth"; 19 | import { getGistDetailsFromUri, isGistDocument } from "../utils"; 20 | 21 | export class GistCodeComment implements Comment { 22 | public contextValue: string; 23 | public body: string | MarkdownString; 24 | public id: string; 25 | public label: string; 26 | public mode: CommentMode = CommentMode.Preview; 27 | public author: CommentAuthorInformation; 28 | 29 | constructor( 30 | comment: GistComment, 31 | public gistId: string, 32 | public parent: CommentThread, 33 | public currentUser: string 34 | ) { 35 | this.id = comment.id; 36 | this.body = new MarkdownString(comment.body); 37 | this.author = { 38 | name: comment.user.login, 39 | iconPath: Uri.parse(comment.user.avatar_url) 40 | }; 41 | this.label = comment.author_association === "OWNER" ? "Owner" : ""; 42 | this.contextValue = comment.user.login === currentUser ? "canEdit" : ""; 43 | } 44 | } 45 | 46 | function commentRange(document: TextDocument) { 47 | return new Range(document.lineCount, 0, document.lineCount, 0); 48 | } 49 | 50 | const documentComments = new Map(); 51 | export async function registerCommentController() { 52 | const controller = comments.createCommentController(EXTENSION_NAME, "Gist"); 53 | controller.commentingRangeProvider = { 54 | provideCommentingRanges: (document) => { 55 | if (isGistDocument(document)) { 56 | return [commentRange(document)]; 57 | } 58 | } 59 | }; 60 | 61 | workspace.onDidOpenTextDocument(async (document) => { 62 | if ( 63 | isGistDocument(document) && 64 | !documentComments.has(document.uri.toString()) 65 | ) { 66 | const { gistId } = getGistDetailsFromUri(document.uri); 67 | const comments = await getGistComments(gistId); 68 | 69 | if (comments.length > 0) { 70 | const thread = controller.createCommentThread( 71 | document.uri, 72 | commentRange(document), 73 | [] 74 | ); 75 | 76 | const currentUser = getCurrentUser(); 77 | 78 | thread.comments = comments.map( 79 | (comment) => new GistCodeComment(comment, gistId, thread, currentUser) 80 | ); 81 | 82 | const showCommentThread = config.get("comments.showThread"); 83 | if ( 84 | showCommentThread === "always" || 85 | (showCommentThread === "whenNotEmpty" && thread.comments.length > 0) 86 | ) { 87 | thread.collapsibleState = CommentThreadCollapsibleState.Expanded; 88 | } else { 89 | thread.collapsibleState = CommentThreadCollapsibleState.Collapsed; 90 | } 91 | 92 | workspace.onDidChangeTextDocument((e) => { 93 | if (e.document === document) { 94 | thread.range = commentRange(document); 95 | } 96 | }); 97 | 98 | documentComments.set(document.uri.toString(), thread); 99 | } 100 | } 101 | }); 102 | } 103 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { observable } from "mobx"; 2 | 3 | interface User { 4 | id: number; 5 | login: string; 6 | avatar_url: string; 7 | html_url: string; 8 | } 9 | 10 | export interface GistFile { 11 | filename?: string; 12 | content?: string; 13 | type?: string; 14 | size?: number; 15 | raw_url?: string; 16 | truncated?: boolean; 17 | } 18 | 19 | export interface GistRevisionStatus { 20 | total: number; 21 | additions: number; 22 | deletions: number; 23 | } 24 | 25 | export interface GistRevision { 26 | user: User; 27 | version: string; 28 | committed_at: string; 29 | change_status: GistRevisionStatus; 30 | } 31 | 32 | export const GistTypes: GistType[] = [ 33 | "code-snippet", 34 | "note", 35 | "code-swing", 36 | "code-swing-template", 37 | "notebook", 38 | "code-swing-tutorial", 39 | "code-tour", 40 | "diagram", 41 | "flash-code" 42 | ]; 43 | 44 | export type GistType = 45 | | "code-snippet" 46 | | "note" 47 | | "code-swing" 48 | | "code-swing-template" 49 | | "notebook" 50 | | "code-swing-tutorial" 51 | | "code-tour" 52 | | "diagram" 53 | | "flash-code"; 54 | 55 | export type GistGroupType = GistType | "tag"; 56 | 57 | export interface Gist { 58 | id: string; 59 | files: { [fileName: string]: GistFile }; 60 | html_url: string; 61 | truncated: boolean; 62 | url: string; 63 | description: string; 64 | owner: User; 65 | public: boolean; 66 | created_at: string; 67 | updated_at: string; 68 | history: GistRevision[]; 69 | git_pull_url: string; 70 | type?: GistType; 71 | tags?: string[]; 72 | } 73 | 74 | export interface GistComment { 75 | id: string; 76 | body: string; 77 | user: User; 78 | created_at: string; 79 | updated_at: string; 80 | author_association: "NONE" | "OWNER"; 81 | } 82 | 83 | export interface FollowedUser { 84 | username: string; 85 | gists: Gist[]; 86 | avatarUrl?: string; 87 | isLoading: boolean; 88 | } 89 | 90 | export enum SortOrder { 91 | alphabetical = "alphabetical", 92 | updatedTime = "updatedTime" 93 | } 94 | 95 | export enum GroupType { 96 | none = "none", 97 | tag = "tag", 98 | tagAndType = "tagAndType" 99 | } 100 | 101 | export interface DailyNotes { 102 | gist: Gist | null; 103 | show: boolean; 104 | } 105 | 106 | export interface Store { 107 | dailyNotes: DailyNotes; 108 | followedUsers: FollowedUser[]; 109 | gists: Gist[]; 110 | archivedGists: Gist[]; 111 | isLoading: boolean; 112 | isSignedIn: boolean; 113 | login: string; 114 | token?: string; 115 | sortOrder: SortOrder; 116 | groupType: GroupType; 117 | starredGists: Gist[]; 118 | canCreateRepos: boolean; 119 | canDeleteRepos: boolean; 120 | unsyncedFiles: Set; 121 | } 122 | 123 | export function findGistInStore(gistId: string) { 124 | if (store.dailyNotes.gist?.id === gistId) { 125 | return store.dailyNotes.gist; 126 | } 127 | 128 | return store.gists 129 | .concat(store.archivedGists) 130 | .concat(store.starredGists) 131 | .find((gist) => gist.id === gistId); 132 | } 133 | 134 | export const store: Store = observable({ 135 | dailyNotes: { 136 | gist: null, 137 | show: false 138 | }, 139 | followedUsers: [], 140 | gists: [], 141 | archivedGists: [], 142 | isLoading: false, 143 | isSignedIn: false, 144 | login: "", 145 | sortOrder: SortOrder.updatedTime, 146 | groupType: GroupType.none, 147 | starredGists: [], 148 | canCreateRepos: false, 149 | canDeleteRepos: false, 150 | unsyncedFiles: new Set() 151 | }); 152 | -------------------------------------------------------------------------------- /src/repos/tree/index.ts: -------------------------------------------------------------------------------- 1 | import { reaction } from "mobx"; 2 | import { 3 | Event, 4 | EventEmitter, 5 | ProviderResult, 6 | TreeDataProvider, 7 | TreeItem, 8 | TreeView, 9 | window 10 | } from "vscode"; 11 | import { EXTENSION_NAME } from "../../constants"; 12 | import { store as authStore } from "../../store"; 13 | import { Repository, RepositoryFile, store } from "../store"; 14 | import { 15 | RepositoryFileBackLinkNode, 16 | RepositoryFileNode, 17 | RepositoryNode 18 | } from "./nodes"; 19 | 20 | class RepositoryTreeProvider implements TreeDataProvider { 21 | private _onDidChangeTreeData = new EventEmitter(); 22 | public readonly onDidChangeTreeData: Event = this._onDidChangeTreeData 23 | .event; 24 | 25 | constructor() { 26 | reaction( 27 | () => [ 28 | authStore.isSignedIn, 29 | store.repos.map((repo) => [ 30 | repo.isLoading, 31 | repo.hasTours, 32 | repo.tree 33 | ? repo.tree.tree.map((item) => [ 34 | item.path, 35 | item.displayName, 36 | item.backLinks 37 | ? item.backLinks.map((link) => link.linePreview) 38 | : null 39 | ]) 40 | : null 41 | ]) 42 | ], 43 | () => { 44 | this._onDidChangeTreeData.fire(); 45 | } 46 | ); 47 | } 48 | 49 | getBackLinkNodes(file: RepositoryFile, repo: Repository) { 50 | return file.backLinks?.map( 51 | (backLink) => new RepositoryFileBackLinkNode(repo.name, backLink) 52 | ); 53 | } 54 | 55 | getFileNodes(parent: Repository | RepositoryFile, repo: Repository) { 56 | return parent.files?.map((file) => new RepositoryFileNode(repo, file)); 57 | } 58 | 59 | getTreeItem = (node: TreeItem) => node; 60 | 61 | getChildren(element?: TreeItem): ProviderResult { 62 | if (!element && authStore.isSignedIn && store.repos.length > 0) { 63 | return store.repos 64 | .slice().sort((a, b) => a.name.localeCompare(b.name)) 65 | .map((repo) => new RepositoryNode(repo)); 66 | } else if (element instanceof RepositoryNode) { 67 | if (element.repo.isLoading) { 68 | return [new TreeItem("Loading repository...")]; 69 | } 70 | 71 | const fileNodes = this.getFileNodes(element.repo, element.repo); 72 | if (fileNodes) { 73 | return fileNodes; 74 | } else { 75 | const addItemSuffix = element.repo.isWiki ? "page" : "file"; 76 | const addFileItem = new TreeItem(`Add new ${addItemSuffix}`); 77 | 78 | const addItemCommand = element.repo.isWiki 79 | ? "addWikiPage" 80 | : "addRepositoryFile"; 81 | 82 | addFileItem.command = { 83 | command: `${EXTENSION_NAME}.${addItemCommand}`, 84 | title: `Add new ${addItemSuffix}`, 85 | arguments: [element] 86 | }; 87 | 88 | return [addFileItem]; 89 | } 90 | } else if (element instanceof RepositoryFileNode) { 91 | if (element.file.isDirectory) { 92 | return this.getFileNodes(element.file, element.repo); 93 | } else if (element.file.backLinks) { 94 | return this.getBackLinkNodes(element.file, element.repo); 95 | } 96 | } 97 | } 98 | 99 | getParent(element: TreeItem): TreeItem | undefined { 100 | return undefined; 101 | } 102 | } 103 | 104 | let treeView: TreeView; 105 | 106 | export function focusRepo(repo: Repository) { 107 | treeView.reveal(new RepositoryNode(repo)); 108 | } 109 | 110 | export function registerTreeProvider() { 111 | treeView = window.createTreeView(`${EXTENSION_NAME}.repos`, { 112 | showCollapseAll: true, 113 | treeDataProvider: new RepositoryTreeProvider(), 114 | canSelectMany: true 115 | }); 116 | } 117 | -------------------------------------------------------------------------------- /src/fileSystem/git.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as os from "os"; 3 | import * as path from "path"; 4 | import { simpleGit as git, SimpleGit } from "simple-git"; 5 | import { store } from "../store"; 6 | import { refreshGist } from "../store/actions"; 7 | import { getToken } from "../store/auth"; 8 | 9 | async function ensureRepo(gistId: string): Promise<[string, SimpleGit]> { 10 | const repoPath = path.join(os.tmpdir(), gistId); 11 | const repoExists = fs.existsSync(repoPath); 12 | 13 | let repo; 14 | if (repoExists) { 15 | repo = git(repoPath); 16 | const isRepo = await repo.checkIsRepo(); 17 | if (isRepo) { 18 | await repo.pull("origin", "main", { "--force": null }); 19 | } else { 20 | await repo.init(); 21 | } 22 | } else { 23 | const token = await getToken(); 24 | const remote = `https://${store.login}:${token}@gist.github.com/${gistId}.git`; 25 | await git(os.tmpdir()).silent(true).clone(remote); 26 | 27 | // Reset the git instance to point 28 | // at the newly cloned folder. 29 | repo = git(repoPath); 30 | } 31 | 32 | return [repoPath, repo]; 33 | } 34 | 35 | export async function addFile( 36 | gistId: string, 37 | fileName: string, 38 | content: Uint8Array 39 | ) { 40 | const [repoPath, repo] = await ensureRepo(gistId); 41 | const filePath = path.join(repoPath, fileName); 42 | 43 | fs.writeFileSync(filePath, content); 44 | 45 | await repo.add(filePath); 46 | await repo.commit(`Adding ${fileName}`); 47 | await repo.push("origin", "main"); 48 | 49 | return refreshGist(gistId); 50 | } 51 | 52 | export async function renameFile( 53 | gistId: string, 54 | fileName: string, 55 | newFileName: string 56 | ) { 57 | const [repoPath, repo] = await ensureRepo(gistId); 58 | 59 | const filePath = path.join(repoPath, fileName); 60 | const newFilePath = path.join(repoPath, newFileName); 61 | fs.renameSync(filePath, newFilePath); 62 | 63 | await repo.add([filePath, newFilePath]); 64 | await repo.commit(`Renaming ${fileName} to ${newFileName}`); 65 | await repo.push("origin", "main"); 66 | 67 | return refreshGist(gistId); 68 | } 69 | 70 | export async function exportToRepo(gistId: string, repoName: string) { 71 | const [, repo] = await ensureRepo(gistId); 72 | const token = await getToken(); 73 | 74 | return pushRemote( 75 | repo, 76 | "export", 77 | `https://${store.login}:${token}@github.com/${store.login}/${repoName}.git` 78 | ); 79 | } 80 | 81 | export async function duplicateGist( 82 | targetGistId: string, 83 | sourceGistId: string 84 | ) { 85 | const [, repo] = await ensureRepo(targetGistId); 86 | const token = await getToken(); 87 | 88 | return pushRemote( 89 | repo, 90 | "duplicate", 91 | `https://${store.login}:${token}@gist.github.com/${sourceGistId}.git` 92 | ); 93 | } 94 | 95 | export async function cloneGistToDirectory( 96 | gistId: string, 97 | parentDirectory: string, 98 | directoryName: string 99 | ) { 100 | const token = await getToken(); 101 | const remote = `https://${store.login}:${token}@gist.github.com/${gistId}.git`; 102 | const targetPath = path.join(parentDirectory, directoryName); 103 | 104 | await git(parentDirectory).silent(true).clone(remote, directoryName); 105 | 106 | return targetPath; 107 | } 108 | 109 | async function pushRemote( 110 | repo: SimpleGit, 111 | remoteName: string, 112 | remoteUrl: string 113 | ) { 114 | const remotes = await repo.getRemotes(false); 115 | if ( 116 | (Array.isArray(remotes) && 117 | !remotes.find((ref) => ref.name === remoteName)) || 118 | // @ts-ignore 119 | !remotes[remoteName] 120 | ) { 121 | await repo.addRemote(remoteName, remoteUrl); 122 | } 123 | 124 | await repo.push(remoteName, "main", { "--force": null }); 125 | await repo.removeRemote(remoteName); 126 | } 127 | -------------------------------------------------------------------------------- /src/repos/comments/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Comment, 3 | CommentAuthorInformation, 4 | CommentController, 5 | CommentMode, 6 | comments, 7 | CommentThread, 8 | CommentThreadCollapsibleState, 9 | ExtensionContext, 10 | MarkdownString, 11 | Range, 12 | Uri, 13 | window, 14 | workspace 15 | } from "vscode"; 16 | import { EXTENSION_NAME } from "../../constants"; 17 | import { GistComment } from "../../store"; 18 | import { getCurrentUser } from "../../store/auth"; 19 | import { RepoFileSystemProvider } from "../fileSystem"; 20 | import { store } from "../store"; 21 | import { getRepoComments } from "./actions"; 22 | import { registerCommentCommands } from "./commands"; 23 | 24 | export class RepoCommitComment implements Comment { 25 | public contextValue: string; 26 | public body: string | MarkdownString; 27 | public id: string; 28 | public label: string; 29 | public mode: CommentMode = CommentMode.Preview; 30 | public author: CommentAuthorInformation; 31 | 32 | constructor( 33 | comment: GistComment, 34 | public repo: string, 35 | public parent: CommentThread, 36 | public currentUser: string 37 | ) { 38 | this.id = comment.id; 39 | this.body = new MarkdownString(comment.body); 40 | 41 | this.author = { 42 | name: comment.user.login, 43 | iconPath: Uri.parse(comment.user.avatar_url) 44 | }; 45 | 46 | this.label = comment.author_association === "OWNER" ? "Owner" : ""; 47 | this.contextValue = currentUser === comment.user.login ? "canEdit" : ""; 48 | } 49 | } 50 | 51 | function commentRange({ line }: any) { 52 | return new Range(line - 1, 0, line - 1, 0); 53 | } 54 | 55 | let controller: CommentController | undefined; 56 | async function checkForComments(uri: Uri) { 57 | const [repo, path] = RepoFileSystemProvider.getFileInfo(uri)!; 58 | const repoComments = await getRepoComments(repo); 59 | const fileComments = repoComments.filter( 60 | (comment: any) => comment.path === path 61 | ); 62 | 63 | const currentUser = getCurrentUser(); 64 | 65 | if (fileComments.length > 0) { 66 | fileComments.forEach((comment: any) => { 67 | const thread = controller!.createCommentThread( 68 | uri, 69 | commentRange(comment), 70 | [] 71 | ); 72 | 73 | thread.collapsibleState = CommentThreadCollapsibleState.Expanded; 74 | thread.canReply = false; 75 | thread.comments = [ 76 | new RepoCommitComment(comment, repo, thread, currentUser) 77 | ]; 78 | }); 79 | } 80 | } 81 | 82 | export async function registerCommentController(context: ExtensionContext) { 83 | workspace.onDidOpenTextDocument(async (document) => { 84 | if (RepoFileSystemProvider.isRepoDocument(document)) { 85 | if (!controller) { 86 | controller = comments.createCommentController( 87 | `${EXTENSION_NAME}:repo`, 88 | "GistPad" 89 | ); 90 | 91 | controller.commentingRangeProvider = { 92 | provideCommentingRanges: (document) => { 93 | if ( 94 | // We don't want to register two comments providers at the same time 95 | !store.isInCodeTour && 96 | RepoFileSystemProvider.isRepoDocument(document) 97 | ) { 98 | return [new Range(0, 0, document.lineCount, 0)]; 99 | } 100 | } 101 | }; 102 | } 103 | 104 | checkForComments(document.uri); 105 | } 106 | }); 107 | 108 | workspace.onDidCloseTextDocument((e) => { 109 | if ( 110 | RepoFileSystemProvider.isRepoDocument(e) && 111 | !window.visibleTextEditors.find((editor) => 112 | RepoFileSystemProvider.isRepoDocument(editor.document) 113 | ) 114 | ) { 115 | controller!.dispose(); 116 | controller = undefined; 117 | } 118 | }); 119 | 120 | registerCommentCommands(context); 121 | } 122 | -------------------------------------------------------------------------------- /src/repos/wiki/commands.ts: -------------------------------------------------------------------------------- 1 | import { commands, ExtensionContext, window, workspace } from "vscode"; 2 | import { EXTENSION_NAME } from "../../constants"; 3 | import { stringToByteArray, withProgress } from "../../utils"; 4 | import { RepoFileSystemProvider } from "../fileSystem"; 5 | import { Repository, store } from "../store"; 6 | import { RepositoryFileNode, RepositoryNode } from "../tree/nodes"; 7 | import { openRepoDocument } from "../utils"; 8 | import { config } from "./config"; 9 | import { getPageFilePath } from "./utils"; 10 | 11 | import moment = require("moment"); 12 | const { titleCase } = require("title-case"); 13 | 14 | async function createWikiPage( 15 | name: string, 16 | repo: Repository, 17 | filePath: string 18 | ) { 19 | const title = titleCase(name); 20 | let fileHeading = `# ${title} 21 | 22 | `; 23 | 24 | const uri = RepoFileSystemProvider.getFileUri(repo.name, filePath); 25 | return workspace.fs.writeFile(uri, stringToByteArray(fileHeading)); 26 | } 27 | 28 | export function registerCommands(context: ExtensionContext) { 29 | // This is a private command that handles dynamically 30 | // creating wiki documents, when the user auto-completes 31 | // a new document link that doesn't exist. 32 | context.subscriptions.push( 33 | commands.registerCommand( 34 | `${EXTENSION_NAME}._createWikiPage`, 35 | async (repo: Repository, name: string) => { 36 | const fileName = getPageFilePath(name); 37 | await createWikiPage(name, repo, fileName); 38 | 39 | // Automatically save the current, in order to ensure 40 | // the newly created backlink is discovered. 41 | await window.activeTextEditor?.document.save(); 42 | } 43 | ) 44 | ); 45 | 46 | context.subscriptions.push( 47 | commands.registerCommand( 48 | `${EXTENSION_NAME}.addWikiPage`, 49 | async (node?: RepositoryNode | RepositoryFileNode) => { 50 | const repo = node?.repo || store.wiki!; 51 | const repoName = repo.name; 52 | 53 | const input = window.createInputBox(); 54 | input.title = `Add wiki page (${repoName})`; 55 | input.prompt = "Enter the name of the new page you'd like to create"; 56 | 57 | input.onDidAccept(async () => { 58 | input.hide(); 59 | 60 | if (input.value) { 61 | const path = getPageFilePath(input.value); 62 | const filePath = 63 | node instanceof RepositoryFileNode 64 | ? `${node.file.path}/${path}` 65 | : path; 66 | 67 | await withProgress("Adding new page...", async () => 68 | createWikiPage(input.value, repo, filePath) 69 | ); 70 | openRepoDocument(repoName, filePath); 71 | } 72 | }); 73 | 74 | input.show(); 75 | } 76 | ) 77 | ); 78 | 79 | context.subscriptions.push( 80 | commands.registerCommand( 81 | `${EXTENSION_NAME}.openTodayPage`, 82 | async (node?: RepositoryNode, displayProgress: boolean = true) => { 83 | const sharedMoment = moment(); 84 | 85 | const filenameFormat = (config.dailyFilenameFormat as string) || "YYYY-MM-DD"; 86 | const fileName = sharedMoment.format(filenameFormat); 87 | 88 | const filePath = getPageFilePath(fileName); 89 | 90 | const titleFormat = workspace 91 | .getConfiguration(EXTENSION_NAME) 92 | .get("wikis.daily.titleFormat", "LL"); 93 | 94 | const repo = node?.repo || store.wiki!; 95 | const repoName = repo.name; 96 | 97 | const pageTitle = sharedMoment.format(titleFormat); 98 | 99 | const uri = RepoFileSystemProvider.getFileUri(repoName, filePath); 100 | 101 | const [, file] = RepoFileSystemProvider.getRepoInfo(uri)!; 102 | 103 | if (!file) { 104 | const writeFile = async () => 105 | createWikiPage(pageTitle, repo, filePath); 106 | 107 | if (displayProgress) { 108 | await withProgress("Adding new page...", writeFile); 109 | } else { 110 | await writeFile(); 111 | } 112 | } 113 | 114 | openRepoDocument(repoName, filePath); 115 | } 116 | ) 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /src/swings/index.ts: -------------------------------------------------------------------------------- 1 | import { reaction } from "mobx"; 2 | import * as vscode from "vscode"; 3 | import { extensions } from "vscode"; 4 | import { EXTENSION_NAME, SWING_FILE } from "../constants"; 5 | import { Gist, store } from "../store"; 6 | import { newGist } from "../store/actions"; 7 | import { GistNode } from "../tree/nodes"; 8 | import { fileNameToUri, isOwnedGist, updateGistTags } from "../utils"; 9 | 10 | class CodeSwingTemplateProvider { 11 | public _onDidChangeTemplate = new vscode.EventEmitter(); 12 | public onDidChangeTemplates = this._onDidChangeTemplate.event; 13 | 14 | public provideTemplates() { 15 | return store.gists 16 | .concat(store.starredGists) 17 | .filter((gist: Gist) => gist.type === "code-swing-template") 18 | .map((gist: Gist) => ({ 19 | title: gist.description, 20 | description: isOwnedGist(gist.id) ? "" : gist.owner, 21 | files: Object.keys(gist.files).map((file) => ({ 22 | filename: file, 23 | content: gist.files[file].content 24 | })) 25 | })); 26 | } 27 | } 28 | 29 | const templateProvider = new CodeSwingTemplateProvider(); 30 | 31 | function loadSwingManifests() { 32 | store.gists 33 | .concat(store.starredGists) 34 | .concat(store.archivedGists) 35 | .forEach(async (gist) => { 36 | const manifest = gist.files[SWING_FILE]; 37 | if (manifest) { 38 | await vscode.workspace.fs.readFile(fileNameToUri(gist.id, SWING_FILE)); 39 | updateGistTags(gist); 40 | } 41 | }); 42 | 43 | templateProvider._onDidChangeTemplate.fire(); 44 | } 45 | 46 | async function newSwing(isPublic: boolean) { 47 | const inputBox = vscode.window.createInputBox(); 48 | inputBox.title = "Create new " + (isPublic ? "" : "secret ") + "swing"; 49 | inputBox.placeholder = "Specify the description of the swing"; 50 | 51 | inputBox.onDidAccept(() => { 52 | inputBox.hide(); 53 | 54 | swingApi.newSwing( 55 | async (files: { filename: string; contents?: string }[]) => { 56 | const gist = await newGist(files, isPublic, inputBox.value, false); 57 | return fileNameToUri(gist.id); 58 | }, 59 | inputBox.title 60 | ); 61 | }); 62 | 63 | inputBox.show(); 64 | } 65 | 66 | let swingApi: any; 67 | export async function registerCodeSwingModule( 68 | context: vscode.ExtensionContext 69 | ) { 70 | reaction( 71 | () => [store.isSignedIn, store.isLoading], 72 | ([isSignedIn, isLoading]) => { 73 | if (isSignedIn && !isLoading) { 74 | loadSwingManifests(); 75 | } 76 | } 77 | ); 78 | 79 | const extension = extensions.getExtension("codespaces-contrib.codeswing"); 80 | if (!extension) { 81 | return; 82 | } 83 | 84 | vscode.commands.executeCommand( 85 | "setContext", 86 | "gistpad:codeSwingEnabled", 87 | true 88 | ); 89 | 90 | if (!extension.isActive) { 91 | await extension.activate(); 92 | } 93 | 94 | swingApi = extension.exports; 95 | 96 | context.subscriptions.push( 97 | vscode.commands.registerCommand( 98 | `${EXTENSION_NAME}.newSwing`, 99 | newSwing.bind(null, true) 100 | ) 101 | ); 102 | 103 | context.subscriptions.push( 104 | vscode.commands.registerCommand( 105 | `${EXTENSION_NAME}.newSecretSwing`, 106 | newSwing.bind(null, false) 107 | ) 108 | ); 109 | 110 | context.subscriptions.push( 111 | vscode.commands.registerCommand( 112 | `${EXTENSION_NAME}.openGistInBlocks`, 113 | async (node: GistNode) => { 114 | vscode.env.openExternal( 115 | vscode.Uri.parse( 116 | `https://bl.ocks.org/${node.gist.owner.login}/${node.gist.id}` 117 | ) 118 | ); 119 | } 120 | ) 121 | ); 122 | 123 | context.subscriptions.push( 124 | vscode.commands.registerCommand( 125 | `${EXTENSION_NAME}.exportGistToCodePen`, 126 | async (node: GistNode) => { 127 | const uri = fileNameToUri(node.gist.id); 128 | swingApi.exportSwingToCodePen(uri); 129 | } 130 | ) 131 | ); 132 | 133 | swingApi.registerTemplateProvider("gist", templateProvider, { 134 | title: "Gists", 135 | description: 136 | "Templates provided by your own gists, and gists you've starred." 137 | }); 138 | } 139 | -------------------------------------------------------------------------------- /src/uriHandler.ts: -------------------------------------------------------------------------------- 1 | import { when } from "mobx"; 2 | import { URLSearchParams } from "url"; 3 | import * as vscode from "vscode"; 4 | import { EXTENSION_ID, EXTENSION_NAME } from "./constants"; 5 | import { store as repoStore } from "./repos/store"; 6 | import { openRepo } from "./repos/store/actions"; 7 | import { store } from "./store"; 8 | import { followUser, openTodayNote } from "./store/actions"; 9 | import { ensureAuthenticated as ensureAuthenticatedInternal } from "./store/auth"; 10 | import { decodeDirectoryName, fileNameToUri, openGist, openGistFile, withProgress } from "./utils"; 11 | 12 | const OPEN_PATH = "/open"; 13 | const GIST_PARAM = "gist"; 14 | const REPO_PARAM = "repo"; 15 | const FILE_PARAM = "file"; 16 | 17 | async function ensureAuthenticated() { 18 | await when(() => store.isSignedIn, { timeout: 3000 }); 19 | await ensureAuthenticatedInternal(); 20 | 21 | if (!store.isSignedIn) throw new Error(); 22 | } 23 | 24 | async function handleFollowRequest(query: URLSearchParams) { 25 | await ensureAuthenticated(); 26 | 27 | const user = query.get("user"); 28 | if (user) { 29 | followUser(user); 30 | vscode.commands.executeCommand("workbench.view.extension.gistpad"); 31 | } 32 | } 33 | 34 | async function handleOpenRequest(query: URLSearchParams) { 35 | const gistId = query.get(GIST_PARAM); 36 | const repoName = query.get(REPO_PARAM); 37 | const file = query.get(FILE_PARAM); 38 | const openAsWorkspace = query.get("workspace") !== null; 39 | 40 | if (gistId) { 41 | if (file) { 42 | const uri = fileNameToUri(gistId, decodeDirectoryName(file)); 43 | openGistFile(uri) 44 | } else { 45 | openGist(gistId, !!openAsWorkspace); 46 | } 47 | } else if (repoName) { 48 | openRepo(repoName, true); 49 | } 50 | } 51 | 52 | async function handleDailyRequest() { 53 | withProgress("Opening daily note...", async () => { 54 | await ensureAuthenticated(); 55 | 56 | // We need to wait for the gists to fully load 57 | // so that we know whether there's already a 58 | // daily gist or not, before opening it. 59 | await when(() => !store.isLoading); 60 | await vscode.commands.executeCommand("gistpad.gists.focus"); 61 | await openTodayNote(false); 62 | }); 63 | } 64 | 65 | async function handleTodayRequest() { 66 | withProgress("Opening today page...", async () => { 67 | await ensureAuthenticated(); 68 | 69 | await when( 70 | () => repoStore.wiki !== undefined && !repoStore.wiki.isLoading, 71 | { timeout: 15000 } 72 | ); 73 | 74 | if (repoStore.wiki) { 75 | await vscode.commands.executeCommand("gistpad.repos.focus"); 76 | await vscode.commands.executeCommand( 77 | `${EXTENSION_NAME}.openTodayPage`, 78 | null, 79 | false 80 | ); 81 | } else { 82 | if ( 83 | await vscode.window.showErrorMessage( 84 | "You don't currently have a wiki repo. Create or open one, then try again.", 85 | "Open repo" 86 | ) 87 | ) { 88 | vscode.commands.executeCommand(`${EXTENSION_NAME}.openRepository`); 89 | } 90 | } 91 | }); 92 | } 93 | 94 | export function createGistPadOpenUrl(gistId: string, file?: string) { 95 | const fileParam = file ? `&${FILE_PARAM}=${file}` : ""; 96 | return `vscode://${EXTENSION_ID}${OPEN_PATH}?${GIST_PARAM}=${gistId}${fileParam}`; 97 | } 98 | 99 | export function createGistPadWebUrl(gistId: string, file: string = "README.md") { 100 | const path = file && file !== "README.md" ? `/${file}` : ""; 101 | return `https://gistpad.dev/#share/${gistId}${path}`; 102 | } 103 | 104 | class GistPadPUriHandler implements vscode.UriHandler { 105 | public async handleUri(uri: vscode.Uri) { 106 | const query = new URLSearchParams(uri.query); 107 | switch (uri.path) { 108 | case OPEN_PATH: 109 | return await handleOpenRequest(query); 110 | case "/follow": 111 | return await handleFollowRequest(query); 112 | case "/daily": 113 | return await handleDailyRequest(); 114 | case "/today": 115 | return await handleTodayRequest(); 116 | } 117 | } 118 | } 119 | 120 | export function registerProtocolHandler() { 121 | if (typeof vscode.window.registerUriHandler === "function") { 122 | vscode.window.registerUriHandler(new GistPadPUriHandler()); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/abstractions/node/images/clipboardToImageBuffer.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { spawn } from "child_process"; 4 | import * as fs from "fs"; 5 | import * as os from "os"; 6 | import * as path from "path"; 7 | import { randomInt } from "./utils/randomInt"; 8 | 9 | function createImagePath() { 10 | return path.join(os.tmpdir(), `${randomInt()}_${randomInt()}.png`); 11 | } 12 | 13 | export class ClipboardToImageBuffer { 14 | public async getImageBits(): Promise { 15 | const platform = process.platform; 16 | const imagePath = createImagePath(); 17 | 18 | switch (platform) { 19 | case "win32": 20 | return await this.getImageFromClipboardWin(imagePath); 21 | case "darwin": 22 | return await this.getImageFromClipboardMac(imagePath); 23 | case "linux": 24 | return await this.getImageFromClipboardLinux(imagePath); 25 | default: 26 | throw new Error(`Not supported platform "${platform}".`); 27 | } 28 | } 29 | 30 | private getImageFromClipboardWin(imagePath: string): Promise { 31 | return new Promise((res, rej) => { 32 | const scriptPath = path.join(__dirname, "./scripts/win.ps1"); 33 | 34 | let command = 35 | "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; 36 | const powershellExisted = fs.existsSync(command); 37 | if (!powershellExisted) { 38 | command = "powershell"; 39 | } 40 | 41 | const powershell = spawn(command, [ 42 | "-noprofile", 43 | "-noninteractive", 44 | "-nologo", 45 | "-sta", 46 | "-executionpolicy", 47 | "unrestricted", 48 | "-windowstyle", 49 | "hidden", 50 | "-file", 51 | scriptPath, 52 | imagePath 53 | ]); 54 | 55 | powershell.on("error", function(e: any) { 56 | const { code } = e as any; 57 | 58 | rej( 59 | code === "ENOENT" 60 | ? "The powershell command is not in you PATH environment variables.Please add it and retry." 61 | : e 62 | ); 63 | }); 64 | 65 | powershell.stdout.on("data", function(data: Buffer) { 66 | const filePath = data.toString().trim(); 67 | 68 | if (filePath === "no image") { 69 | rej("No image found."); 70 | } 71 | 72 | const binary = fs.readFileSync(filePath); 73 | 74 | if (!binary) { 75 | rej("No temporary image file read"); 76 | } 77 | 78 | res(binary); 79 | fs.unlinkSync(imagePath); 80 | }); 81 | }); 82 | } 83 | 84 | private getImageFromClipboardMac(imagePath: string): Promise { 85 | return new Promise((res, rej) => { 86 | const scriptPath = path.join(__dirname, "./scripts/mac.applescript"); 87 | const ascript = spawn("osascript", [scriptPath, imagePath]); 88 | ascript.on("error", (e: any) => { 89 | rej(e); 90 | }); 91 | 92 | ascript.stdout.on("data", (data: Buffer) => { 93 | const filePath = data.toString().trim(); 94 | if (filePath === "no image") { 95 | return rej("No image found."); 96 | } 97 | 98 | const binary = fs.readFileSync(filePath); 99 | if (!binary) { 100 | return rej("No temporary image file read."); 101 | } 102 | 103 | fs.unlinkSync(imagePath); 104 | res(binary); 105 | }); 106 | }); 107 | } 108 | 109 | private getImageFromClipboardLinux(imagePath: string): Promise { 110 | return new Promise((res, rej) => { 111 | const scriptPath = path.join(__dirname, "./scripts/linux.sh"); 112 | 113 | const ascript = spawn("sh", [scriptPath, imagePath]); 114 | ascript.on("error", function(e: any) { 115 | rej(e); 116 | }); 117 | 118 | ascript.stdout.on("data", (data: Buffer) => { 119 | const result = data.toString().trim(); 120 | if (result === "no xclip") { 121 | const message = "You need to install xclip command first."; 122 | return rej(message); 123 | } 124 | 125 | if (result === "no image") { 126 | const message = "Cannot get image in the clipboard."; 127 | return rej(message); 128 | } 129 | 130 | const binary = fs.readFileSync(result); 131 | 132 | if (!binary) { 133 | return rej("No temporary image file read."); 134 | } 135 | 136 | res(binary); 137 | fs.unlinkSync(imagePath); 138 | }); 139 | }); 140 | } 141 | } 142 | 143 | export const clipboardToImageBuffer = new ClipboardToImageBuffer(); 144 | -------------------------------------------------------------------------------- /src/store/auth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | authentication, 3 | AuthenticationSession, 4 | commands, 5 | window 6 | } from "vscode"; 7 | import { store } from "."; 8 | import { EXTENSION_NAME } from "../constants"; 9 | import { refreshGists } from "./actions"; 10 | const GitHub = require("github-base"); 11 | 12 | let loginSession: string | undefined; 13 | 14 | export function getCurrentUser() { 15 | return store.login; 16 | } 17 | 18 | const STATE_CONTEXT_KEY = `${EXTENSION_NAME}:state`; 19 | const STATE_SIGNED_IN = "SignedIn"; 20 | const STATE_SIGNED_OUT = "SignedOut"; 21 | 22 | const GIST_SCOPE = "gist"; 23 | const REPO_SCOPE = "repo"; 24 | const DELETE_REPO_SCOPE = "delete_repo"; 25 | 26 | // TODO: Replace github-base with octokit 27 | export async function getApi(newToken?: string) { 28 | const token = newToken || (await getToken()); 29 | return new GitHub({ token }); 30 | } 31 | 32 | const TOKEN_RESPONSE = "Sign in"; 33 | export async function ensureAuthenticated() { 34 | if (store.isSignedIn) { 35 | return; 36 | } 37 | 38 | const response = await window.showErrorMessage( 39 | "You need to sign-in with GitHub to perform this operation.", 40 | TOKEN_RESPONSE 41 | ); 42 | if (response === TOKEN_RESPONSE) { 43 | await signIn(); 44 | } 45 | } 46 | 47 | async function getSession( 48 | isInteractiveSignIn: boolean = false, 49 | includeDeleteRepoScope: boolean = false 50 | ) { 51 | const scopes = [GIST_SCOPE, REPO_SCOPE]; 52 | if (includeDeleteRepoScope) { 53 | scopes.push(DELETE_REPO_SCOPE); 54 | } 55 | 56 | try { 57 | if (isInteractiveSignIn) { 58 | isSigningIn = true; 59 | } 60 | 61 | const session = await authentication.getSession("github", scopes, { 62 | createIfNone: isInteractiveSignIn 63 | }); 64 | 65 | if (session) { 66 | loginSession = session?.id; 67 | } 68 | 69 | isSigningIn = false; 70 | 71 | return session; 72 | } catch { } 73 | } 74 | 75 | export async function getToken() { 76 | return store.token; 77 | } 78 | 79 | async function markUserAsSignedIn( 80 | session: AuthenticationSession, 81 | refreshUI: boolean = true 82 | ) { 83 | loginSession = session.id; 84 | 85 | store.token = session.accessToken; 86 | store.isSignedIn = true; 87 | store.login = session.account.label; 88 | store.canCreateRepos = session.scopes.includes(REPO_SCOPE); 89 | store.canDeleteRepos = session.scopes.includes(DELETE_REPO_SCOPE); 90 | 91 | if (refreshUI) { 92 | commands.executeCommand("setContext", STATE_CONTEXT_KEY, STATE_SIGNED_IN); 93 | await refreshGists(); 94 | } 95 | } 96 | 97 | function markUserAsSignedOut() { 98 | loginSession = undefined; 99 | 100 | store.login = ""; 101 | store.isSignedIn = false; 102 | 103 | commands.executeCommand("setContext", STATE_CONTEXT_KEY, STATE_SIGNED_OUT); 104 | } 105 | 106 | let isSigningIn = false; 107 | export async function signIn() { 108 | const session = await getSession(true); 109 | 110 | if (session) { 111 | window.showInformationMessage( 112 | "You're successfully signed in and can now manage your GitHub gists and repositories!" 113 | ); 114 | await markUserAsSignedIn(session); 115 | return true; 116 | } 117 | } 118 | 119 | export async function elevateSignin() { 120 | const session = await getSession(true, true); 121 | 122 | if (session) { 123 | await markUserAsSignedIn(session, false); 124 | return true; 125 | } 126 | } 127 | 128 | async function attemptSilentSignin(refreshUI: boolean = true) { 129 | const session = await getSession(); 130 | 131 | if (session) { 132 | await markUserAsSignedIn(session, refreshUI); 133 | } else { 134 | await markUserAsSignedOut(); 135 | } 136 | } 137 | 138 | export async function initializeAuth() { 139 | authentication.onDidChangeSessions(async (e) => { 140 | if (e.provider.id === "github") { 141 | // @ts-ignore 142 | if (e.added.length > 0) { 143 | // This session was added based on a GistPad-triggered 144 | // sign-in, and so we don't need to do anything further to process it. 145 | if (isSigningIn) { 146 | isSigningIn = false; 147 | return; 148 | } 149 | 150 | // The end-user just signed in to Gist via the 151 | // VS Code account UI, and therefore, we need 152 | // to grab the session token/etc. 153 | await attemptSilentSignin(); 154 | // @ts-ignore 155 | } else if (e.changed.length > 0 && e.changed.includes(loginSession)) { 156 | // TODO: Validate when this actually fires 157 | await attemptSilentSignin(false); 158 | } 159 | // @ts-ignore 160 | else if (e.removed.length > 0 && e.removed.includes(loginSession)) { 161 | // TODO: Implement sign out support 162 | } 163 | } 164 | }); 165 | 166 | await attemptSilentSignin(); 167 | } 168 | --------------------------------------------------------------------------------