├── .eslintrc.js ├── .github └── workflows │ └── dispatch.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── css_custom_data.json ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── LICENSE ├── README.md ├── demo.gif ├── docs ├── icon-transparent.png └── icon.png ├── git.d.ts ├── package.json ├── postcss.config.js ├── screenshots ├── command-panel.png ├── cron-editor.png ├── gui-view.png ├── http-step.png ├── show-preview-button.png └── sql-step.png ├── snowpack.config.js ├── src ├── extension.ts ├── flatConfigEditor.ts ├── git.ts ├── lib.ts ├── types.ts └── webviews │ ├── src │ ├── App.tsx │ ├── Header.tsx │ ├── Jobs.tsx │ ├── Setting.tsx │ ├── Step.tsx │ ├── StepConfig.tsx │ ├── Triggers.tsx │ ├── VSCodeAPI.tsx │ ├── Workflow.tsx │ ├── error-state.tsx │ ├── index.tsx │ ├── settings │ │ ├── CronChooser.tsx │ │ ├── FieldWithDescription.tsx │ │ ├── FilePicker.tsx │ │ ├── FilePreview.tsx │ │ ├── HttpEndpointPreview.tsx │ │ └── SecretInput.tsx │ ├── store.ts │ ├── validation.ts │ └── vscode.css │ └── types │ └── static.d.ts ├── tailwind.config.js ├── tsconfig-webview.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /**@type {import('eslint').Linter.Config} */ 2 | // eslint-disable-next-line no-undef 3 | module.exports = { 4 | root: true, 5 | parser: '@typescript-eslint/parser', 6 | plugins: [ 7 | '@typescript-eslint', 8 | ], 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | ], 13 | rules: { 14 | 'semi': [2, "always"], 15 | '@typescript-eslint/no-unused-vars': 0, 16 | '@typescript-eslint/no-explicit-any': 0, 17 | '@typescript-eslint/explicit-module-boundary-types': 0, 18 | '@typescript-eslint/no-non-null-assertion': 0, 19 | }, 20 | ignorePatterns: ["src/webviews"], 21 | env: ["node"] 22 | }; -------------------------------------------------------------------------------- /.github/workflows/dispatch.yml: -------------------------------------------------------------------------------- 1 | name: Repo Events Repository Dispatch 2 | 3 | on: 4 | - issues 5 | - issue_comment 6 | - pull_request 7 | 8 | jobs: 9 | preflight-job: 10 | name: Dispatch 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Print Outputs 14 | env: 15 | outputs: ${{ toJSON(github) }} 16 | run: | 17 | echo outputs: $outputs 18 | - name: Repository Dispatch 19 | uses: peter-evans/repository-dispatch@v1 20 | with: 21 | token: ${{ secrets.PAT }} 22 | repository: githubocto/next-devex-workflows # repo to send event to 23 | event-type: repoevents # name of the custom event 24 | client-payload: '{"event": ${{ toJSON(github) }}}' 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | .snowpack 6 | yarn-error.log -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "semi": false, 4 | "singleQuote": true, 5 | "overrides": [ 6 | { 7 | "files": "tailwind.config.js", 8 | "options": { 9 | "quoteProps": "consistent", 10 | "printWidth": 200 11 | } 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /.vscode/css_custom_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "atDirectives": [ 3 | { 4 | "name": "@tailwind", 5 | "description": "Use the @tailwind directive to insert Tailwind’s `base`, `components`, `utilities`, and `screens` styles into your CSS.", 6 | "references": [ 7 | { 8 | "name": "Tailwind’s “Functions & Directives” documentation", 9 | "url": "https://tailwindcss.com/docs/functions-and-directives/#tailwind" 10 | } 11 | ] 12 | }, 13 | { 14 | "name": "@screen", 15 | "description": "Use the @screen directive to apply Tailwind's responsive breakpoints", 16 | "references": [ 17 | { 18 | "name": "Tailwind’s “Functions & Directives” documentation", 19 | "url": "https://tailwindcss.com/docs/functions-and-directives/#screen" 20 | } 21 | ] 22 | }, 23 | { 24 | "name": "@layer", 25 | "description": "Use the @layer directive to tell Tailwind which \"bucket\" a set of custom styles belong to.", 26 | "references": [ 27 | { 28 | "name": "Tailwind’s “Functions & Directives” documentation", 29 | "url": "https://tailwindcss.com/docs/functions-and-directives/#layer" 30 | } 31 | ] 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "dbaeumer.vscode-eslint", 8 | "csstools.postcss", 9 | "esbenp.prettier-vscode", 10 | "bradlc.vscode-tailwindcss" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [{ 8 | "name": "Run Extension", 9 | "type": "extensionHost", 10 | "request": "launch", 11 | "runtimeExecutable": "${execPath}", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/out/**/*.js" 17 | ], 18 | "preLaunchTask": "npm: watch" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "css.customData": [".vscode/css_custom_data.json"] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "script": "build", 8 | "label": "snowpack-build", 9 | "type": "npm" 10 | }, 11 | { 12 | "script": "dev", 13 | "label": "snowpack-build-dev", 14 | "type": "npm", 15 | "problemMatcher": { 16 | "owner": "typescript", 17 | "fileLocation": "absolute", 18 | "pattern": { 19 | "regexp": "^(.*):(\\d+):(\\d+):\\s+(warning|error):\\s+(.*)$", 20 | "file": 1, 21 | "line": 2, 22 | "column": 3, 23 | "severity": 4, 24 | "message": 5 25 | }, 26 | "background": { 27 | "activeOnStart": false, 28 | "beginsPattern": "^\\[nodemon\\] starting", 29 | "endsPattern": "^\\[nodemon\\] clean exit" 30 | } 31 | }, 32 | "isBackground": true 33 | }, 34 | { 35 | "type": "npm", 36 | "script": "watch", 37 | "problemMatcher": "$tsc-watch", 38 | "isBackground": true, 39 | "presentation": { 40 | "reveal": "never" 41 | }, 42 | "group": { 43 | "kind": "build", 44 | "isDefault": true 45 | }, 46 | "dependsOn": ["snowpack-build-dev"] 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | src/** 4 | .gitignore 5 | .yarnrc 6 | vsc-extension-quickstart.md 7 | **/tsconfig.json 8 | **/.eslintrc.json 9 | **/*.map 10 | **/*.ts 11 | node_modules 12 | !node_modules/vscode-codicons/dist/codicon.css 13 | !node_modules/vscode-codicons/dist/codicon.ttf 14 | .parcel-cache 15 | tailwind.config.json 16 | webpack.config.js 17 | screenshots -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 GitHub Next 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flat Editor VSCode Extension 2 | 3 |

4 | 5 |

6 | 7 | 👉🏽 👉🏽 👉🏽 **Full writeup**: [Flat Data Project](https://octo.github.com/projects/flat-data) 👈🏽 👈🏽 👈🏽 8 | 9 | Flat Editor is a VSCode extension that steps you through the process of creating a [Flat Data Action](https://github.com/githubocto/flat), which makes it easy to fetch data and commit it to your repository. 10 | 11 | Flat Data is a GitHub action which makes it easy to fetch data and commit it to your repository as flatfiles. The action is intended to be run on a schedule, retrieving data from any supported target and creating a commit if there is any change to the fetched data. Flat Data builds on the [“git scraping” approach pioneered by Simon Willison](https://simonwillison.net/2020/Oct/9/git-scraping/) to offer a simple pattern for bringing working datasets into your repositories and versioning them, because developing against local datasets is faster and easier than working with data over the wire. 12 | 13 | ## Usage 14 | 15 | ### VSCode 16 | 17 | To use Flat Editor, first [install the extension](https://marketplace.visualstudio.com/items?itemName=githubocto.flat). 18 | 19 | If you're starting from an empty repository, invoke the VSCode Command Palette via the shortcut Cmd+Shift+P and select **Initialize Flat YML File** 20 | 21 | ![Screenshot of VSCode Command Palette](./screenshots/command-panel.png) 22 | 23 | This will generate a `flat.yml` file in the `.github/workflows` directory, and will open a GUI through which you can configure your Flat action. 24 | 25 | ![Screenshot of Flat Action configuration GUI](./screenshots/gui-view.png) 26 | 27 | At any given time, you can view the raw content of the underlying YML file via the **View the raw YAML** button in the GUI, or via the following button at the top right of your VSCode workspace. 28 | 29 | ![Screenshot of 'Show Preview' VSCode toolbar button](./screenshots/show-preview-button.png) 30 | 31 | Changes to `flat.yml` are saved automatically when using the GUI, but feel free to save manually via Cmd+S if the habit is as deeply engrained for you as it is for us 😀 32 | 33 | ## Action Configuration 34 | 35 | Currently, Flat supports the ingestion of data via the two following sources: 36 | 37 | 1. Publicly accessible HTTP endpoint 38 | 2. SQL Database (accessible via connection string) 39 | 40 | Flat assumes that you'd like to run your workflow on a given schedule, and to that end exposes a GUI for specifying a CRON job as part of the action definition. We've selected a handful of default values, but feel free to enter any valid CRON string here. We'll even validate the CRON for you as you type! 41 | 42 | ![Screenshot of CRON GUI](./screenshots/cron-editor.png) 43 | 44 | ### Creating an HTTP action 45 | 46 | ![Screenshot of HTTP creation view for Flat extension](./screenshots/http-step.png) 47 | 48 | To create an HTTP action, you'll be asked for the following inputs: 49 | 50 | 1. A result filename (the filename and extension that your results will be written to, e.g., `vaccination-data.json`). 51 | 2. A publicly accessible URL (we'll try to render a helpful inline preview of the response from this endpoint if we can) 52 | 3. An optional path to a [postprocessing script](https://github.com/githubocto/flat#postprocessing), if you wish to perform further transformation or work on the fetched date 53 | 54 | ### Creating a SQL action 55 | 56 | ![Screenshot of SQL creation view for Flat extension](./screenshots/sql-step.png) 57 | 58 | To create a SQL action, you'll be asked for the following inputs: 59 | 60 | 1. A result filename (the filename and extension that your results will be written to, e.g., `vaccination-data.json`). 61 | 2. A path to a SQL query 62 | 3. A database connection string \* 63 | 4. An optional path to a [postprocessing script](https://github.com/githubocto/flat#postprocessing), if you wish to perform further transformation or work on the fetched date 64 | 65 | \* Note that we will encrypt this value and create a [GitHub secret](https://docs.github.com/en/actions/reference/encrypted-secrets) in your repository for this connection string. No sensitive data will be committed to your repository. Keep in mind that your repository must have an upstream remote on github.com in order for us to create the secret. 66 | 67 | ## Running Your Action 68 | 69 | After you've added the requisite steps to your Flat action, push your changes to your GitHub repository. Your workflow should run automatically. Additionally, under the hood, the extension lists your optional postprocessing and/or SQL query files as workflow triggers, meaning the workflow will run anytime these files change. You can run your workflows manually, too, thanks to the `workflow_dispatch: {}` value that the extension adds to your Flat action. 70 | 71 | ```yaml 72 | workflow_dispatch: {} 73 | push: 74 | paths: 75 | - .github/workflows/flat.yml 76 | - ./rearrange-vax-data.ts 77 | ``` 78 | 79 | ## Development and Deployment 80 | 81 | Deploy a new version with: 82 | 83 | First make sure you're a part of the githubocto marketplace team [here](https://marketplace.visualstudio.com/manage/publishers/githubocto). 84 | 85 | 86 | 1. Get a PAT [here](https://dev.azure.com/githubocto/_usersSettings/tokens) (first time) 87 | 2. `vsce login githubocto` (first time) 88 | 3. `vsce publish [minor|major|patch]` This will create a new version and update package.json accordingly. 89 | 4. `git push` the change to `package.json` 90 | 91 | ## Issues 92 | 93 | If you run into any trouble or have questions, feel free to [open an issue](https://github.com/githubocto/flat-editor/issues). Sharing your `flat.yml` with us in the issue will help us understand what might be happening. 94 | 95 | ❤️ GitHub OCTO 96 | 97 | ## License 98 | 99 | [MIT](LICENSE) 100 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githubocto/flat-editor/7cf54f56c4bdda31e79257e16cc268b4415ebff4/demo.gif -------------------------------------------------------------------------------- /docs/icon-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githubocto/flat-editor/7cf54f56c4bdda31e79257e16cc268b4415ebff4/docs/icon-transparent.png -------------------------------------------------------------------------------- /docs/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githubocto/flat-editor/7cf54f56c4bdda31e79257e16cc268b4415ebff4/docs/icon.png -------------------------------------------------------------------------------- /git.d.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import { Uri, Event, Disposable, ProviderResult, Command } from 'vscode' 7 | export { ProviderResult } from 'vscode' 8 | 9 | export interface Git { 10 | readonly path: string 11 | } 12 | 13 | export interface InputBox { 14 | value: string 15 | } 16 | 17 | export const enum ForcePushMode { 18 | Force, 19 | ForceWithLease, 20 | } 21 | 22 | export const enum RefType { 23 | Head, 24 | RemoteHead, 25 | Tag, 26 | } 27 | 28 | export interface Ref { 29 | readonly type: RefType 30 | readonly name?: string 31 | readonly commit?: string 32 | readonly remote?: string 33 | } 34 | 35 | export interface UpstreamRef { 36 | readonly remote: string 37 | readonly name: string 38 | } 39 | 40 | export interface Branch extends Ref { 41 | readonly upstream?: UpstreamRef 42 | readonly ahead?: number 43 | readonly behind?: number 44 | } 45 | 46 | export interface Commit { 47 | readonly hash: string 48 | readonly message: string 49 | readonly parents: string[] 50 | readonly authorDate?: Date 51 | readonly authorName?: string 52 | readonly authorEmail?: string 53 | readonly commitDate?: Date 54 | } 55 | 56 | export interface Submodule { 57 | readonly name: string 58 | readonly path: string 59 | readonly url: string 60 | } 61 | 62 | export interface Remote { 63 | readonly name: string 64 | readonly fetchUrl?: string 65 | readonly pushUrl?: string 66 | readonly isReadOnly: boolean 67 | } 68 | 69 | export const enum Status { 70 | INDEX_MODIFIED, 71 | INDEX_ADDED, 72 | INDEX_DELETED, 73 | INDEX_RENAMED, 74 | INDEX_COPIED, 75 | 76 | MODIFIED, 77 | DELETED, 78 | UNTRACKED, 79 | IGNORED, 80 | INTENT_TO_ADD, 81 | 82 | ADDED_BY_US, 83 | ADDED_BY_THEM, 84 | DELETED_BY_US, 85 | DELETED_BY_THEM, 86 | BOTH_ADDED, 87 | BOTH_DELETED, 88 | BOTH_MODIFIED, 89 | } 90 | 91 | export interface Change { 92 | /** 93 | * Returns either `originalUri` or `renameUri`, depending 94 | * on whether this change is a rename change. When 95 | * in doubt always use `uri` over the other two alternatives. 96 | */ 97 | readonly uri: Uri 98 | readonly originalUri: Uri 99 | readonly renameUri: Uri | undefined 100 | readonly status: Status 101 | } 102 | 103 | export interface RepositoryState { 104 | readonly HEAD: Branch | undefined 105 | readonly refs: Ref[] 106 | readonly remotes: Remote[] 107 | readonly submodules: Submodule[] 108 | readonly rebaseCommit: Commit | undefined 109 | 110 | readonly mergeChanges: Change[] 111 | readonly indexChanges: Change[] 112 | readonly workingTreeChanges: Change[] 113 | 114 | readonly onDidChange: Event 115 | } 116 | 117 | export interface RepositoryUIState { 118 | readonly selected: boolean 119 | readonly onDidChange: Event 120 | } 121 | 122 | /** 123 | * Log options. 124 | */ 125 | export interface LogOptions { 126 | /** Max number of log entries to retrieve. If not specified, the default is 32. */ 127 | readonly maxEntries?: number 128 | readonly path?: string 129 | } 130 | 131 | export interface CommitOptions { 132 | all?: boolean | 'tracked' 133 | amend?: boolean 134 | signoff?: boolean 135 | signCommit?: boolean 136 | empty?: boolean 137 | noVerify?: boolean 138 | requireUserConfig?: boolean 139 | useEditor?: boolean 140 | verbose?: boolean 141 | postCommitCommand?: string 142 | } 143 | 144 | export interface FetchOptions { 145 | remote?: string 146 | ref?: string 147 | all?: boolean 148 | prune?: boolean 149 | depth?: number 150 | } 151 | 152 | export interface BranchQuery { 153 | readonly remote?: boolean 154 | readonly pattern?: string 155 | readonly count?: number 156 | readonly contains?: string 157 | } 158 | 159 | export interface Repository { 160 | readonly rootUri: Uri 161 | readonly inputBox: InputBox 162 | readonly state: RepositoryState 163 | readonly ui: RepositoryUIState 164 | 165 | getConfigs(): Promise<{ key: string; value: string }[]> 166 | getConfig(key: string): Promise 167 | setConfig(key: string, value: string): Promise 168 | getGlobalConfig(key: string): Promise 169 | 170 | getObjectDetails( 171 | treeish: string, 172 | path: string 173 | ): Promise<{ mode: string; object: string; size: number }> 174 | detectObjectType( 175 | object: string 176 | ): Promise<{ mimetype: string; encoding?: string }> 177 | buffer(ref: string, path: string): Promise 178 | show(ref: string, path: string): Promise 179 | getCommit(ref: string): Promise 180 | 181 | add(paths: string[]): Promise 182 | revert(paths: string[]): Promise 183 | clean(paths: string[]): Promise 184 | 185 | apply(patch: string, reverse?: boolean): Promise 186 | diff(cached?: boolean): Promise 187 | diffWithHEAD(): Promise 188 | diffWithHEAD(path: string): Promise 189 | diffWith(ref: string): Promise 190 | diffWith(ref: string, path: string): Promise 191 | diffIndexWithHEAD(): Promise 192 | diffIndexWithHEAD(path: string): Promise 193 | diffIndexWith(ref: string): Promise 194 | diffIndexWith(ref: string, path: string): Promise 195 | diffBlobs(object1: string, object2: string): Promise 196 | diffBetween(ref1: string, ref2: string): Promise 197 | diffBetween(ref1: string, ref2: string, path: string): Promise 198 | 199 | hashObject(data: string): Promise 200 | 201 | createBranch(name: string, checkout: boolean, ref?: string): Promise 202 | deleteBranch(name: string, force?: boolean): Promise 203 | getBranch(name: string): Promise 204 | getBranches(query: BranchQuery): Promise 205 | setBranchUpstream(name: string, upstream: string): Promise 206 | 207 | getMergeBase(ref1: string, ref2: string): Promise 208 | 209 | tag(name: string, upstream: string): Promise 210 | deleteTag(name: string): Promise 211 | 212 | status(): Promise 213 | checkout(treeish: string): Promise 214 | 215 | addRemote(name: string, url: string): Promise 216 | removeRemote(name: string): Promise 217 | renameRemote(name: string, newName: string): Promise 218 | 219 | fetch(options?: FetchOptions): Promise 220 | fetch(remote?: string, ref?: string, depth?: number): Promise 221 | pull(unshallow?: boolean): Promise 222 | push( 223 | remoteName?: string, 224 | branchName?: string, 225 | setUpstream?: boolean, 226 | force?: ForcePushMode 227 | ): Promise 228 | 229 | blame(path: string): Promise 230 | log(options?: LogOptions): Promise 231 | 232 | commit(message: string, opts?: CommitOptions): Promise 233 | } 234 | 235 | export interface RemoteSource { 236 | readonly name: string 237 | readonly description?: string 238 | readonly url: string | string[] 239 | } 240 | 241 | export interface RemoteSourceProvider { 242 | readonly name: string 243 | readonly icon?: string // codicon name 244 | readonly supportsQuery?: boolean 245 | getRemoteSources(query?: string): ProviderResult 246 | getBranches?(url: string): ProviderResult 247 | publishRepository?(repository: Repository): Promise 248 | } 249 | 250 | export interface RemoteSourcePublisher { 251 | readonly name: string 252 | readonly icon?: string // codicon name 253 | publishRepository(repository: Repository): Promise 254 | } 255 | 256 | export interface Credentials { 257 | readonly username: string 258 | readonly password: string 259 | } 260 | 261 | export interface CredentialsProvider { 262 | getCredentials(host: Uri): ProviderResult 263 | } 264 | 265 | export type CommitCommand = Command & { description?: string } 266 | 267 | export interface PostCommitCommandsProvider { 268 | getCommands(repository: Repository): CommitCommand[] 269 | } 270 | 271 | export interface PushErrorHandler { 272 | handlePushError( 273 | repository: Repository, 274 | remote: Remote, 275 | refspec: string, 276 | error: Error & { gitErrorCode: GitErrorCodes } 277 | ): Promise 278 | } 279 | 280 | export type APIState = 'uninitialized' | 'initialized' 281 | 282 | export interface PublishEvent { 283 | repository: Repository 284 | branch?: string 285 | } 286 | 287 | export interface API { 288 | readonly state: APIState 289 | readonly onDidChangeState: Event 290 | readonly onDidPublish: Event 291 | readonly git: Git 292 | readonly repositories: Repository[] 293 | readonly onDidOpenRepository: Event 294 | readonly onDidCloseRepository: Event 295 | 296 | toGitUri(uri: Uri, ref: string): Uri 297 | getRepository(uri: Uri): Repository | null 298 | init(root: Uri): Promise 299 | openRepository(root: Uri): Promise 300 | 301 | registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable 302 | registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable 303 | registerCredentialsProvider(provider: CredentialsProvider): Disposable 304 | registerPostCommitCommandsProvider( 305 | provider: PostCommitCommandsProvider 306 | ): Disposable 307 | registerPushErrorHandler(handler: PushErrorHandler): Disposable 308 | } 309 | 310 | export interface GitExtension { 311 | readonly enabled: boolean 312 | readonly onDidChangeEnablement: Event 313 | 314 | /** 315 | * Returns a specific API version. 316 | * 317 | * Throws error if git extension is disabled. You can listen to the 318 | * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event 319 | * to know when the extension becomes enabled/disabled. 320 | * 321 | * @param version Version number. 322 | * @returns API instance 323 | */ 324 | getAPI(version: 1): API 325 | } 326 | 327 | export const enum GitErrorCodes { 328 | BadConfigFile = 'BadConfigFile', 329 | AuthenticationFailed = 'AuthenticationFailed', 330 | NoUserNameConfigured = 'NoUserNameConfigured', 331 | NoUserEmailConfigured = 'NoUserEmailConfigured', 332 | NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', 333 | NotAGitRepository = 'NotAGitRepository', 334 | NotAtRepositoryRoot = 'NotAtRepositoryRoot', 335 | Conflict = 'Conflict', 336 | StashConflict = 'StashConflict', 337 | UnmergedChanges = 'UnmergedChanges', 338 | PushRejected = 'PushRejected', 339 | RemoteConnectionError = 'RemoteConnectionError', 340 | DirtyWorkTree = 'DirtyWorkTree', 341 | CantOpenResource = 'CantOpenResource', 342 | GitNotFound = 'GitNotFound', 343 | CantCreatePipe = 'CantCreatePipe', 344 | PermissionDenied = 'PermissionDenied', 345 | CantAccessRemote = 'CantAccessRemote', 346 | RepositoryNotFound = 'RepositoryNotFound', 347 | RepositoryIsLocked = 'RepositoryIsLocked', 348 | BranchNotFullyMerged = 'BranchNotFullyMerged', 349 | NoRemoteReference = 'NoRemoteReference', 350 | InvalidBranchName = 'InvalidBranchName', 351 | BranchAlreadyExists = 'BranchAlreadyExists', 352 | NoLocalChanges = 'NoLocalChanges', 353 | NoStashFound = 'NoStashFound', 354 | LocalChangesOverwritten = 'LocalChangesOverwritten', 355 | NoUpstreamBranch = 'NoUpstreamBranch', 356 | IsInSubmodule = 'IsInSubmodule', 357 | WrongCase = 'WrongCase', 358 | CantLockRef = 'CantLockRef', 359 | CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', 360 | PatchDoesNotApply = 'PatchDoesNotApply', 361 | NoPathFound = 'NoPathFound', 362 | UnknownPath = 'UnknownPath', 363 | EmptyCommitMessage = 'EmptyCommitMessage', 364 | } 365 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flat", 3 | "displayName": "Flat Editor", 4 | "description": "An editor for flat-data configurations", 5 | "version": "0.24.0", 6 | "publisher": "githubocto", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/githubocto/flat-editor" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/githubocto/flat-editor/issues", 13 | "email": "idan@github.com" 14 | }, 15 | "engines": { 16 | "vscode": "^1.54.0" 17 | }, 18 | "icon": "docs/icon-transparent.png", 19 | "categories": [ 20 | "Other" 21 | ], 22 | "activationEvents": [ 23 | "onLanguage:yaml", 24 | "onCustomEditor:flat.config", 25 | "onCommand:flat.initializeFlatYml" 26 | ], 27 | "babel": { 28 | "presets": [ 29 | "@babel/preset-env", 30 | "@babel/preset-typescript" 31 | ], 32 | "plugins": [ 33 | "@babel/plugin-proposal-class-properties", 34 | "@babel/plugin-transform-runtime" 35 | ] 36 | }, 37 | "extensionDependencies": [ 38 | "vscode.git" 39 | ], 40 | "main": "./out/extension.js", 41 | "contributes": { 42 | "customEditors": [ 43 | { 44 | "viewType": "flat.config", 45 | "displayName": "Flat Config", 46 | "selector": [ 47 | { 48 | "filenamePattern": "**/.github/workflows/flat.yml" 49 | } 50 | ], 51 | "priority": "default" 52 | } 53 | ], 54 | "commands": [ 55 | { 56 | "command": "flat.showPreview", 57 | "title": "Show Editor", 58 | "category": "Flat Editor", 59 | "icon": "$(go-to-file)" 60 | }, 61 | { 62 | "command": "flat.showRaw", 63 | "title": "Show Raw YAML File", 64 | "category": "Flat Editor", 65 | "icon": "$(go-to-file)" 66 | }, 67 | { 68 | "command": "flat.showPreviewToSide", 69 | "title": "Show Editor to the Side", 70 | "category": "Flat Editor", 71 | "icon": "$(go-to-file)" 72 | }, 73 | { 74 | "command": "flat.showRawToSide", 75 | "title": "Show Raw YAML File to the Side", 76 | "category": "Flat Editor", 77 | "icon": "$(go-to-file)" 78 | }, 79 | { 80 | "command": "flat.initializeFlatYml", 81 | "title": "Initialize Flat YML File", 82 | "category": "Flat Editor" 83 | } 84 | ], 85 | "menus": { 86 | "editor/title": [ 87 | { 88 | "command": "flat.showPreview", 89 | "alt": "flat.showPreviewToSide", 90 | "group": "navigation", 91 | "when": "resourcePath =~ /.github/workflows/flat.yml$/ && editorLangId == yaml && activeEditor == workbench.editors.files.textFileEditor" 92 | }, 93 | { 94 | "command": "flat.showRaw", 95 | "alt": "flat.showRawToSide", 96 | "group": "navigation", 97 | "when": "resourcePath =~ /.github/workflows/flat.yml$/ && activeEditor == WebviewEditor" 98 | } 99 | ] 100 | } 101 | }, 102 | "scripts": { 103 | "vscode:prepublish": "run-s clean compile", 104 | "clean": "rm -rf out dist", 105 | "compile": "run-s webpack:build webview:build", 106 | "lint": "eslint . --ext .ts,.tsx", 107 | "watch": "tsc -watch -p ./", 108 | "format": "prettier --write **/*.ts", 109 | "dev": "nodemon --watch src/webviews/ -e 'ts,tsx,css' --exec snowpack build", 110 | "build": "TAILWIND_MODE='build' snowpack build", 111 | "webpack:build": "webpack", 112 | "webview:build": "TAILWIND_MODE='build' snowpack build" 113 | }, 114 | "devDependencies": { 115 | "@babel/core": "^7.13.14", 116 | "@babel/plugin-proposal-class-properties": "^7.13.0", 117 | "@babel/plugin-transform-runtime": "^7.13.10", 118 | "@babel/preset-env": "^7.13.12", 119 | "@babel/preset-typescript": "^7.13.0", 120 | "@snowpack/plugin-dotenv": "^2.0.5", 121 | "@snowpack/plugin-postcss": "^1.2.1", 122 | "@snowpack/plugin-typescript": "^1.2.1", 123 | "@types/lodash-es": "^4.17.4", 124 | "@types/node": "^12.12.0", 125 | "@types/react": "^17.0.3", 126 | "@types/react-dom": "^17.0.2", 127 | "@types/snowpack-env": "^2.3.3", 128 | "@types/tailwindcss": "^2.0.2", 129 | "@types/vscode": "^1.54.0", 130 | "@typescript-eslint/eslint-plugin": "^4.16.0", 131 | "@typescript-eslint/parser": "^4.16.0", 132 | "autoprefixer": "^10.2.5", 133 | "babel-loader": "^8.2.2", 134 | "eslint": "^7.21.0", 135 | "nodemon": "^2.0.7", 136 | "npm-run-all": "^4.1.5", 137 | "postcss": "^8.4.5", 138 | "prettier": "^2.2.1", 139 | "snowpack": "^3.1.0", 140 | "tailwindcss": "^3.0.17", 141 | "ts-loader": "^8.1.0", 142 | "typescript": "^4.2.2", 143 | "webpack": "^5.30.0", 144 | "webpack-cli": "^4.6.0" 145 | }, 146 | "dependencies": { 147 | "@octokit/rest": "^18.5.2", 148 | "@radix-ui/react-dropdown-menu": "^0.0.17", 149 | "@reach/combobox": "^0.16.5", 150 | "@types/isomorphic-fetch": "^0.0.35", 151 | "@vscode/codicons": "^0.0.27", 152 | "@vscode/webview-ui-toolkit": "^0.9.0", 153 | "cronstrue": "^1.123.0", 154 | "fast-glob": "^3.2.5", 155 | "git-url-parse": "^11.4.4", 156 | "immer": "^9.0.1", 157 | "isomorphic-fetch": "^3.0.0", 158 | "lodash-es": "^4.17.21", 159 | "nanoid": "^3.1.22", 160 | "react": "^17.0.1", 161 | "react-dom": "^17.0.1", 162 | "react-error-boundary": "^3.1.4", 163 | "ts-debounce": "^3.0.0", 164 | "tweetsodium": "0.0.4", 165 | "yaml": "^2.0.0-4", 166 | "yup": "^0.32.9", 167 | "zod": "^3.0.0-alpha.33", 168 | "zustand": "^3.3.3" 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /screenshots/command-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githubocto/flat-editor/7cf54f56c4bdda31e79257e16cc268b4415ebff4/screenshots/command-panel.png -------------------------------------------------------------------------------- /screenshots/cron-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githubocto/flat-editor/7cf54f56c4bdda31e79257e16cc268b4415ebff4/screenshots/cron-editor.png -------------------------------------------------------------------------------- /screenshots/gui-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githubocto/flat-editor/7cf54f56c4bdda31e79257e16cc268b4415ebff4/screenshots/gui-view.png -------------------------------------------------------------------------------- /screenshots/http-step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githubocto/flat-editor/7cf54f56c4bdda31e79257e16cc268b4415ebff4/screenshots/http-step.png -------------------------------------------------------------------------------- /screenshots/show-preview-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githubocto/flat-editor/7cf54f56c4bdda31e79257e16cc268b4415ebff4/screenshots/show-preview-button.png -------------------------------------------------------------------------------- /screenshots/sql-step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/githubocto/flat-editor/7cf54f56c4bdda31e79257e16cc268b4415ebff4/screenshots/sql-step.png -------------------------------------------------------------------------------- /snowpack.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("snowpack").SnowpackUserConfig } */ 2 | module.exports = { 3 | mount: { 4 | 'src/webviews/public': { url: '/', static: true }, 5 | 'src/webviews/src': { url: '/' }, 6 | 'node_modules/@vscode/codicons/dist': { url: '/public', static: true }, 7 | }, 8 | plugins: [ 9 | '@snowpack/plugin-dotenv', 10 | '@snowpack/plugin-postcss', 11 | [ 12 | '@snowpack/plugin-typescript', 13 | { args: '--project ./tsconfig-webview.json' }, 14 | ], 15 | ], 16 | routes: [ 17 | /* Enable an SPA Fallback in development: */ 18 | // {"match": "routes", "src": ".*", "dest": "/index.html"}, 19 | ], 20 | optimize: { 21 | bundle: true, 22 | minify: true, 23 | target: 'es2020', 24 | entrypoints: ['index.js'], // one per webview 25 | }, 26 | packageOptions: {}, 27 | devOptions: { 28 | output: 'stream', 29 | }, 30 | buildOptions: { 31 | out: 'out/webviews', 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import * as path from 'path' 3 | import * as fs from 'fs' 4 | import { stringify } from 'yaml' 5 | 6 | import { FlatConfigEditor } from './flatConfigEditor' 7 | 8 | export async function activate(context: vscode.ExtensionContext) { 9 | const editor = FlatConfigEditor.register(context) 10 | context.subscriptions.push(editor) 11 | 12 | const showEditor = 13 | ({ isPreview = false, onSide = false }) => 14 | () => { 15 | const workspaceRootUri = vscode.workspace.workspaceFolders?.[0].uri 16 | if (!workspaceRootUri) return 17 | const flatFileUri = vscode.Uri.joinPath( 18 | workspaceRootUri, 19 | '.github/workflows', 20 | 'flat.yml' 21 | ) 22 | 23 | vscode.commands.executeCommand( 24 | 'vscode.openWith', 25 | flatFileUri, 26 | isPreview ? 'flat.config' : 'default', 27 | onSide ? { viewColumn: vscode.ViewColumn.Beside, preview: false } : {} 28 | ) 29 | } 30 | 31 | context.subscriptions.push( 32 | vscode.commands.registerCommand( 33 | 'flat.showPreview', 34 | showEditor({ isPreview: true, onSide: false }) 35 | ) 36 | ) 37 | context.subscriptions.push( 38 | vscode.commands.registerCommand( 39 | 'flat.showRaw', 40 | showEditor({ isPreview: false, onSide: false }) 41 | ) 42 | ) 43 | context.subscriptions.push( 44 | vscode.commands.registerCommand( 45 | 'flat.showPreviewToSide', 46 | showEditor({ isPreview: true, onSide: true }) 47 | ) 48 | ) 49 | context.subscriptions.push( 50 | vscode.commands.registerCommand( 51 | 'flat.showRawToSide', 52 | showEditor({ isPreview: false, onSide: true }) 53 | ) 54 | ) 55 | 56 | context.subscriptions.push( 57 | vscode.commands.registerCommand('flat.initializeFlatYml', async () => { 58 | let rootPath: vscode.WorkspaceFolder 59 | const folders = vscode.workspace.workspaceFolders 60 | 61 | if (!folders) { 62 | return 63 | } 64 | rootPath = folders[0] 65 | 66 | const workflowsDir = path.join(rootPath.uri.fsPath, '.github/workflows') 67 | const flatYmlPath = path.join(workflowsDir, 'flat.yml') 68 | 69 | if (fs.existsSync(flatYmlPath)) { 70 | showEditor({ isPreview: true })() 71 | return 72 | } 73 | 74 | fs.mkdirSync(workflowsDir, { recursive: true }) 75 | 76 | const flatStub = { 77 | name: 'data', 78 | on: { 79 | schedule: [{ cron: '0 0 * * *' }], 80 | workflow_dispatch: {}, 81 | push: { 82 | paths: ['.github/workflows/flat.yml'], 83 | }, 84 | }, 85 | jobs: { 86 | scheduled: { 87 | 'runs-on': 'ubuntu-latest', 88 | steps: [ 89 | { 90 | name: 'Setup deno', 91 | uses: 'denoland/setup-deno@main', 92 | with: { 93 | 'deno-version': 'v1.10.x', 94 | }, 95 | }, 96 | { 97 | name: 'Check out repo', 98 | uses: 'actions/checkout@v2', 99 | }, 100 | ], 101 | }, 102 | }, 103 | } 104 | 105 | fs.writeFileSync(path.join(workflowsDir, 'flat.yml'), stringify(flatStub)) 106 | showEditor({ isPreview: true })() 107 | }) 108 | ) 109 | } 110 | -------------------------------------------------------------------------------- /src/flatConfigEditor.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import * as fg from 'fast-glob' 3 | import * as fetch from 'isomorphic-fetch' 4 | import { parse, stringify } from 'yaml' 5 | import { debounce } from 'ts-debounce' 6 | 7 | import { Octokit } from '@octokit/rest' 8 | import { VSCodeGit } from './git' 9 | import { getNonce, getSession } from './lib' 10 | import type { FlatState } from './types' 11 | 12 | const sodium = require('tweetsodium') 13 | 14 | export class FlatConfigEditor implements vscode.CustomTextEditorProvider { 15 | public static register(context: vscode.ExtensionContext): vscode.Disposable { 16 | const provider = new FlatConfigEditor(context) 17 | const providerRegistration = vscode.window.registerCustomEditorProvider( 18 | FlatConfigEditor.viewType, 19 | provider 20 | ) 21 | return providerRegistration 22 | } 23 | 24 | private static readonly viewType = 'flat.config' 25 | 26 | constructor(private readonly context: vscode.ExtensionContext) {} 27 | 28 | // Called when our custom editor is opened. 29 | public async resolveCustomTextEditor( 30 | document: vscode.TextDocument, 31 | webviewPanel: vscode.WebviewPanel, 32 | _token: vscode.CancellationToken 33 | ): Promise { 34 | const updateWebview = async (document: vscode.TextDocument) => { 35 | if (vscode.window.activeTextEditor) { 36 | webviewPanel.webview.html = await this.getHtmlForWebview( 37 | webviewPanel.webview 38 | ) 39 | } else { 40 | const rawFlatYaml = document.getText() 41 | const parsedConfig = parse(rawFlatYaml) 42 | 43 | webviewPanel.webview.postMessage({ 44 | command: 'updateState', 45 | config: parsedConfig, 46 | }) 47 | } 48 | } 49 | 50 | const updateWebviewState = async () => { 51 | const rawFlatYaml = document.getText() 52 | const parsedConfig = parse(rawFlatYaml) 53 | webviewPanel.webview.postMessage({ 54 | command: 'updateState', 55 | config: parsedConfig, 56 | }) 57 | } 58 | 59 | const changeDocumentSubscription = vscode.workspace.onDidSaveTextDocument( 60 | e => { 61 | if (e.uri.toString() === document.uri.toString()) { 62 | updateWebview(e) 63 | } 64 | } 65 | ) 66 | 67 | // Make sure we get rid of the listener when our editor is closed. 68 | webviewPanel.onDidDispose(() => { 69 | changeDocumentSubscription.dispose() 70 | }) 71 | 72 | // Setup initial content for the webview 73 | webviewPanel.webview.options = { 74 | enableScripts: true, 75 | } 76 | 77 | try { 78 | webviewPanel.webview.html = await this.getHtmlForWebview( 79 | webviewPanel.webview 80 | ) 81 | } catch (e) { 82 | await vscode.window.showErrorMessage( 83 | "Please make sure you're in a repository with a valid upstream GitHub remote" 84 | ) 85 | await vscode.commands.executeCommand( 86 | 'workbench.action.revertAndCloseActiveEditor' 87 | ) 88 | // For whatever reason, this doesn't close the webview. 89 | await webviewPanel.dispose() 90 | } 91 | 92 | // Receive message from the webview. 93 | webviewPanel.webview.onDidReceiveMessage(async e => { 94 | switch (e.type) { 95 | case 'openEditor': 96 | this.showEditor(e.data) 97 | break 98 | case 'updateText': 99 | this.updateTextDocument(document, e.data) 100 | break 101 | case 'storeSecret': 102 | this.storeSecret(webviewPanel, e.data) 103 | break 104 | case 'refreshFiles': 105 | this.loadFiles(webviewPanel) 106 | break 107 | case 'getFileContents': 108 | this.loadFileContents(webviewPanel, e.data) 109 | break 110 | case 'refreshState': 111 | updateWebviewState() 112 | case 'getUrlContents': 113 | this.loadUrlContents(webviewPanel, e.data) 114 | break 115 | case 'refreshGitDetails': 116 | webviewPanel.webview.html = await this.getHtmlForWebview( 117 | webviewPanel.webview 118 | ) 119 | break 120 | case 'previewFile': 121 | const workspaceRootUri = vscode.workspace.workspaceFolders?.[0].uri 122 | if (!workspaceRootUri) { 123 | return 124 | } 125 | const uri = vscode.Uri.joinPath(workspaceRootUri, e.data) 126 | const doc = await vscode.workspace.openTextDocument(uri) 127 | vscode.window.showTextDocument(doc) 128 | break 129 | default: 130 | break 131 | } 132 | }) 133 | } 134 | 135 | /** 136 | * Get the static html used for the editor webviews. 137 | */ 138 | private async getHtmlForWebview(webview: vscode.Webview): Promise { 139 | // Local path to script and css for the webview 140 | const scriptUri = webview.asWebviewUri( 141 | vscode.Uri.joinPath(this.context.extensionUri, 'out/webviews/index.js') 142 | ) 143 | 144 | const styleVSCodeUri = webview.asWebviewUri( 145 | vscode.Uri.joinPath(this.context.extensionUri, 'out/webviews/index.css') 146 | ) 147 | const codiconsUri = webview.asWebviewUri( 148 | vscode.Uri.joinPath( 149 | this.context.extensionUri, 150 | 'out/webviews/public/codicon.css' 151 | ) 152 | ) 153 | 154 | // Use a nonce to whitelist which scripts can be run 155 | const nonce = getNonce() 156 | 157 | const workspaceRootUri = vscode.workspace.workspaceFolders?.[0].uri 158 | if (!workspaceRootUri) { 159 | throw new Error('No workspace open') 160 | } 161 | 162 | const flatFileUri = vscode.Uri.joinPath( 163 | workspaceRootUri, 164 | '.github/workflows', 165 | 'flat.yml' 166 | ) 167 | const document = await vscode.workspace.openTextDocument(flatFileUri) 168 | const rawFlatYaml = document.getText() 169 | 170 | const dirName = workspaceRootUri.path.substring( 171 | workspaceRootUri.path.lastIndexOf('/') + 1 172 | ) 173 | 174 | let name, 175 | owner = '' 176 | 177 | try { 178 | const details = await vscode.window.withProgress( 179 | { 180 | location: vscode.ProgressLocation.Notification, 181 | }, 182 | async progress => { 183 | progress.report({ 184 | message: `Checking for GitHub repository in directory: ${dirName}`, 185 | }) 186 | 187 | return await this.getRepoDetails() 188 | } 189 | ) 190 | owner = details.owner || '' 191 | name = details.name 192 | } catch (e) { 193 | console.error('Error getting GitHub repository details', e) 194 | } 195 | 196 | const gitRepo = owner && name ? `${owner}/${name}` : '' 197 | 198 | return /* html */ ` 199 | 200 | 201 | 202 | 203 | 204 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 218 | 219 | Flat Editor 220 | 221 | 222 |
223 | 224 | 225 | ` 226 | } 227 | 228 | private saveDocument(document: vscode.TextDocument) { 229 | document.save() 230 | } 231 | 232 | debouncedSave = debounce(this.saveDocument, 300) 233 | 234 | /** 235 | * Write out the yaml to a given document. 236 | */ 237 | private async updateTextDocument(_: vscode.TextDocument, data: any) { 238 | const workspaceRootUri = vscode.workspace.workspaceFolders?.[0].uri 239 | if (!workspaceRootUri) { 240 | throw new Error('No workspace open') 241 | } 242 | 243 | const flatFileUri = vscode.Uri.joinPath( 244 | workspaceRootUri, 245 | '.github/workflows', 246 | 'flat.yml' 247 | ) 248 | const document = await vscode.workspace.openTextDocument(flatFileUri) 249 | const currentText = document.getText() 250 | 251 | // todo 252 | const edit = new vscode.WorkspaceEdit() 253 | 254 | const newText = this.serializeWorkflow(data) 255 | if (currentText === newText) return 256 | 257 | // Replaces the entire document every time 258 | // TODO, maybe: more specific edits 259 | edit.replace( 260 | document.uri, 261 | new vscode.Range(0, 0, document.lineCount, 0), 262 | newText 263 | ) 264 | await vscode.workspace.applyEdit(edit) 265 | this.debouncedSave(document) 266 | } 267 | 268 | private serializeWorkflow(data: FlatState): string { 269 | // const doc: FlatYamlDoc = { 270 | // name: 'Flat', 271 | // }, 272 | // jobs: {}, 273 | // } 274 | // if (data.triggerPush) { 275 | // if (data.triggerSchedule) { 276 | // cron: data.triggerSchedule, 277 | // }, 278 | // ] 279 | // } 280 | 281 | // data.jobs.forEach(j => { 282 | // doc.jobs[j.name] = { 283 | // { 284 | // name: 'Checkout repo', 285 | // uses: 'actions/checkout@v2', 286 | // }, 287 | // ...j.job.steps, 288 | // ], 289 | // } 290 | // }) 291 | const serialized = stringify(data) 292 | return serialized 293 | } 294 | 295 | private async storeSecret( 296 | webviewPanel: vscode.WebviewPanel, 297 | data: SecretData 298 | ) { 299 | const { fieldName, value } = data 300 | 301 | let session = await getSession({ 302 | createIfNone: true, 303 | }) 304 | 305 | // if (!session) { 306 | // session = await getSession({ 307 | // createIfNone: true, 308 | // }) 309 | // } 310 | 311 | if (!session) return 312 | 313 | const { owner, name } = await this.getRepoDetails() 314 | if (!owner || !name) return 315 | 316 | const octokit = new Octokit({ 317 | auth: session.accessToken, 318 | }) 319 | // Go time! Let's create a secret for the encrypted conn string. 320 | const keyRes = await octokit.actions.getRepoPublicKey({ 321 | owner, 322 | repo: name, 323 | }) 324 | const key = keyRes.data.key 325 | // Convert the message and key to Uint8Array's (Buffer implements that interface) 326 | const messageBytes = Buffer.from(value) 327 | const keyBytes = Buffer.from(key, 'base64') 328 | // Encrypt using LibSodium. 329 | const encryptedBytes = sodium.seal(messageBytes, keyBytes) 330 | // Base64 the encrypted secret 331 | const encrypted = Buffer.from(encryptedBytes).toString('base64') 332 | const keyId = keyRes.data.key_id 333 | try { 334 | await octokit.actions.createOrUpdateRepoSecret({ 335 | owner: owner, 336 | repo: name, 337 | secret_name: fieldName, 338 | encrypted_value: encrypted, 339 | key_id: keyId, 340 | }) 341 | 342 | await webviewPanel.webview.postMessage({ 343 | command: 'storeSecretResponse', 344 | fieldName, 345 | status: 'success', 346 | }) 347 | } catch (e) { 348 | await vscode.window.showErrorMessage( 349 | "Oh no! We weren't able to create a secret for your connection string." 350 | ) 351 | await webviewPanel.webview.postMessage({ 352 | command: 'storeSecretResponse', 353 | fieldName, 354 | status: 'error', 355 | }) 356 | } 357 | } 358 | 359 | public showEditor = ({ 360 | isPreview = false, 361 | onSide = false, 362 | }: ShowEditorOptions): void => { 363 | const workspaceRootUri = vscode.workspace.workspaceFolders?.[0].uri 364 | if (!workspaceRootUri) return 365 | const flatFileUri = vscode.Uri.joinPath( 366 | workspaceRootUri, 367 | '.github/workflows', 368 | 'flat.yml' 369 | ) 370 | 371 | vscode.commands.executeCommand( 372 | 'vscode.openWith', 373 | flatFileUri, 374 | isPreview ? 'flat.config' : 'default', 375 | onSide ? { viewColumn: vscode.ViewColumn.Beside, preview: false } : {} 376 | ) 377 | } 378 | 379 | private loadFiles = async (webviewPanel: vscode.WebviewPanel) => { 380 | const workspaceRootUri = vscode.workspace.workspaceFolders?.[0].uri 381 | if (!workspaceRootUri) return 382 | 383 | const files = await fg( 384 | [ 385 | workspaceRootUri.path + '/**/*', 386 | `!${workspaceRootUri.path}/.git`, 387 | `!${workspaceRootUri.path}/.vscode`, 388 | `!${workspaceRootUri.path}/**/node_modules`, 389 | ], 390 | { dot: true } 391 | ) 392 | const parsedFiles = files.map( 393 | file => `.${file.split(workspaceRootUri.path)[1]}` 394 | ) 395 | 396 | await webviewPanel.webview.postMessage({ 397 | command: 'updateFiles', 398 | files: parsedFiles, 399 | }) 400 | } 401 | 402 | private loadFileContents = async ( 403 | webviewPanel: vscode.WebviewPanel, 404 | filePath: string 405 | ) => { 406 | const workspaceRootUri = vscode.workspace.workspaceFolders?.[0].uri 407 | if (!workspaceRootUri) return 408 | if (!filePath) return 409 | 410 | const fileUri = vscode.Uri.joinPath(workspaceRootUri, filePath) 411 | const document = await vscode.workspace.openTextDocument(fileUri) 412 | const rawText = document.getText() 413 | 414 | await webviewPanel.webview.postMessage({ 415 | command: 'returnFileContents', 416 | file: filePath, 417 | contents: rawText, 418 | }) 419 | } 420 | 421 | private getRepoDetails(): Promise<{ name?: string; owner?: string }> { 422 | return new Promise(async (resolve, reject) => { 423 | try { 424 | const gitClient = new VSCodeGit() 425 | await gitClient.waitForRepo() 426 | const { name, owner } = gitClient.repoDetails 427 | resolve({ name, owner }) 428 | } catch (e) { 429 | reject('Couldnt activate git') 430 | } 431 | }) 432 | } 433 | 434 | private loadUrlContents = async ( 435 | webviewPanel: vscode.WebviewPanel, 436 | url: string 437 | ) => { 438 | // FIX: For whatever reason, we're getting an undefined URL when the extension mounts with a certain Flat YML 439 | if (!url) return 440 | const res = await fetch(url) 441 | const contents = await res.text() 442 | 443 | await webviewPanel.webview.postMessage({ 444 | command: 'returnUrlContents', 445 | url: url, 446 | contents: contents, 447 | }) 448 | } 449 | } 450 | 451 | interface ShowEditorOptions { 452 | isPreview?: boolean 453 | onSide?: boolean 454 | } 455 | interface SecretData { 456 | fieldName: string 457 | value: string 458 | } 459 | -------------------------------------------------------------------------------- /src/git.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { API, GitExtension } from '../git' 3 | 4 | const GitUrlParse = require('git-url-parse') 5 | 6 | export class VSCodeGit { 7 | extension: GitExtension 8 | api: API 9 | 10 | constructor() { 11 | const gitExtension = vscode.extensions.getExtension('vscode.git')?.exports 12 | 13 | if (!gitExtension) { 14 | throw new Error('Git extension not found') 15 | } 16 | 17 | this.extension = gitExtension 18 | this.api = gitExtension.getAPI(1) 19 | } 20 | 21 | waitForRepo(): Promise<{ name: string; owner: string }> { 22 | let count = 0 23 | 24 | return new Promise((resolve, reject) => { 25 | let interval = setInterval(() => { 26 | try { 27 | const remotes = this.repository.state.remotes 28 | if (remotes.length > 0) { 29 | const remote = remotes[0] 30 | const parsed = GitUrlParse(remote.pushUrl) 31 | resolve({ name: parsed.name, owner: parsed.owner }) 32 | } else { 33 | if (count === 3) { 34 | clearInterval(interval) 35 | reject(new Error("Couldn't get repo details")) 36 | } 37 | count++ 38 | } 39 | } catch (e) { 40 | reject(new Error("Couldn't get repo details")) 41 | } 42 | }, 1000) 43 | }) 44 | } 45 | 46 | get repoDetails() { 47 | const remotes = this.repository.state.remotes 48 | if (remotes.length === 0) { 49 | throw new Error( 50 | "No remotes found. Are you sure you've created an upstream repo?" 51 | ) 52 | } 53 | 54 | const remote = remotes[0] 55 | const parsed = GitUrlParse(remote.pushUrl) 56 | return { 57 | name: parsed.name, 58 | owner: parsed.owner, 59 | } 60 | } 61 | 62 | get repository() { 63 | return this.api.repositories[0] 64 | } 65 | 66 | get workingTreeChanges() { 67 | if (!this.repository) { 68 | throw new Error("No repository found. Are you sure you're in a repo?") 69 | } 70 | 71 | return this.repository.state.workingTreeChanges 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | 3 | export function getNonce() { 4 | let text = '' 5 | const possible = 6 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 7 | for (let i = 0; i < 32; i++) { 8 | text += possible.charAt(Math.floor(Math.random() * possible.length)) 9 | } 10 | return text 11 | } 12 | 13 | interface GetSessionParams { 14 | createIfNone: boolean 15 | } 16 | 17 | const GITHUB_AUTH_PROVIDER_ID = 'github' 18 | const SCOPES = ['user:email', 'repo'] 19 | 20 | export function getSession(params: GetSessionParams) { 21 | const { createIfNone } = params 22 | return vscode.authentication.getSession(GITHUB_AUTH_PROVIDER_ID, SCOPES, { 23 | createIfNone, 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import * as vscode from 'vscode' 3 | 4 | export type PullBaseConfig = { 5 | downloaded_filename?: string 6 | postprocess?: string 7 | } 8 | export type PullHttpConfig = { 9 | http_url: string 10 | } & PullBaseConfig 11 | 12 | export type PullSqlConfig = { 13 | sql_connstring: string 14 | sql_queryfile: string 15 | } & PullBaseConfig 16 | 17 | export type PullConfig = PullHttpConfig | PullSqlConfig 18 | 19 | export type PushConfig = {} // TODO: once we have a push action 20 | 21 | export type PullStep = { 22 | name: string 23 | uses: 'githubocto/flat-pull@v1' 24 | with: PullConfig 25 | } 26 | 27 | export type PushStep = { 28 | name: string 29 | uses: 'githubocto/flat-push@v1' 30 | with: PushConfig 31 | } 32 | 33 | export type CheckoutStep = { 34 | name: string 35 | uses: 'actions/checkout@v2' 36 | } 37 | 38 | export type FlatStep = { 39 | name: 'Fetch data' 40 | uses: 'githubocto/flat@main' 41 | with: PullConfig 42 | } 43 | 44 | export type DenoStep = { 45 | name: 'Setup deno' 46 | uses: 'denoland/setup-deno@main' 47 | with: { 48 | 'deno-version': 'v1.10.x' 49 | } 50 | } 51 | 52 | export type Step = CheckoutStep | FlatStep | DenoStep 53 | 54 | export type FlatJob = { 55 | 'runs-on': string 56 | steps: Step[] 57 | } 58 | 59 | interface OnFlatState { 60 | workflow_dispatch?: any 61 | push?: { 62 | paths: string[] 63 | } 64 | schedule: { 65 | cron: string 66 | }[] 67 | } 68 | export type FlatState = { 69 | name: string 70 | on: OnFlatState 71 | jobs: { 72 | scheduled: { 73 | steps: Step[] 74 | } 75 | } 76 | } 77 | 78 | export type FlatYamlStep = { 79 | name: string 80 | uses: string 81 | with?: { 82 | [k: string]: string 83 | } 84 | } 85 | 86 | export type FlatYamlJob = { 87 | 'runs-on': 'ubuntu-latest' 88 | steps: [ 89 | { 90 | name: 'Setup deno' 91 | uses: 'denoland/setup-deno@main' 92 | with: { 93 | 'deno-version': 'v1.10.x' 94 | } 95 | }, 96 | { 97 | name: 'Checkout repo' 98 | uses: 'actions/checkout@v2' 99 | }, 100 | ...Array 101 | ] 102 | } 103 | 104 | export type FlatYamlDoc = { 105 | name: 'Flat' 106 | on: { 107 | push?: { 108 | paths: ['.github/workflows/flat.yml'] 109 | } 110 | workflow_dispatch: null 111 | schedule?: [ 112 | { 113 | cron: string 114 | } 115 | ] 116 | } 117 | jobs: { 118 | scheduled: FlatYamlJob 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/webviews/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import flatten from 'lodash-es/flatten' 3 | import uniq from 'lodash-es/uniq' 4 | import Jobs from './Jobs' 5 | import useFlatConfigStore from './store' 6 | import Triggers from './Triggers' 7 | import { flatStateValidationSchema } from './validation' 8 | import { VSCodeAPI } from './VSCodeAPI' 9 | import { FlatStep, PullSqlConfig } from '../../types' 10 | import { ErrorState } from './error-state' 11 | import { 12 | VSCodeButton, 13 | VSCodeDivider, 14 | VSCodeLink, 15 | } from '@vscode/webview-ui-toolkit/react' 16 | 17 | interface AppProps {} 18 | 19 | function App({}: AppProps) { 20 | const { state, setErrors, isStubData, gitRepo } = useFlatConfigStore() 21 | 22 | if (!gitRepo) { 23 | return 24 | } 25 | 26 | const showErrorState = state.jobs.scheduled.steps 27 | .filter(step => step.uses.includes('githubocto/flat')) 28 | .some(step => { 29 | return !Boolean((step as FlatStep)?.with?.downloaded_filename) 30 | }) 31 | 32 | useEffect(() => { 33 | flatStateValidationSchema 34 | .validate(state, { abortEarly: false }) 35 | .then(function () { 36 | setErrors([]) 37 | }) 38 | .catch(function (err) { 39 | setErrors(err.inner) 40 | }) 41 | 42 | if (isStubData) return 43 | 44 | // Add push paths for all postprocessing files to "state" 45 | let cloned = { ...state } 46 | 47 | if (cloned.on.push) { 48 | // @ts-ignore 49 | cloned.on.push.paths = uniq([ 50 | '.github/workflows/flat.yml', 51 | ...flatten( 52 | state.jobs.scheduled.steps.map(step => { 53 | let files = [] 54 | if (!(step as FlatStep).with) return [] 55 | if ((step as FlatStep).with.postprocess !== undefined) { 56 | files.push((step as FlatStep).with.postprocess) 57 | } 58 | if (((step as FlatStep).with as PullSqlConfig).sql_queryfile) { 59 | files.push( 60 | ((step as FlatStep).with as PullSqlConfig).sql_queryfile 61 | ) 62 | } 63 | return files 64 | }) 65 | ), 66 | ]) 67 | } 68 | 69 | VSCodeAPI.postMessage({ 70 | type: 'updateText', 71 | data: cloned, 72 | }) 73 | }, [state]) 74 | 75 | const handleOpenRaw = () => { 76 | VSCodeAPI.postMessage({ 77 | type: 'openEditor', 78 | data: { isPreview: false, onSide: false }, 79 | }) 80 | } 81 | 82 | const actionsUrl = gitRepo && `https://github.com/${gitRepo}/actions` 83 | 84 | return ( 85 |
86 |
87 |
88 |

89 | Flat Editor 90 |

91 |
92 |
93 |

94 | This is a gui for setting up a Flat Action, which will pull external 95 | data and update it using GitHub Actions. 96 |

97 |
98 | View the raw YAML 99 |
100 | 101 | 102 | 103 | 104 |
105 | {showErrorState ? ( 106 |
107 | 108 |

109 | Make sure all of your steps have a{' '} 110 | downloaded_filename specified! 111 |

112 |
113 | ) : ( 114 |

115 | Commit, push, and check out your new Action on{' '} 116 | 117 | on GitHub 118 | {' '} 119 | It should run automatically, once pushed. 120 |

121 | )} 122 |
123 |
124 | ) 125 | } 126 | 127 | export default App 128 | -------------------------------------------------------------------------------- /src/webviews/src/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | type HeaderProps = { 4 | title: string 5 | description: string 6 | hasHoverState?: boolean 7 | } 8 | 9 | const Header: FunctionComponent = props => { 10 | const hasHoverState = props.hasHoverState === false ? false : true 11 | return ( 12 |
17 |
18 | {props.title} 19 |
20 |
{props.description}
21 | {props.children &&
{props.children}
} 22 |
23 | ) 24 | } 25 | 26 | export default Header 27 | -------------------------------------------------------------------------------- /src/webviews/src/Jobs.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | import Header from './Header' 4 | import { Step } from './Step' 5 | import useFlatConfigStore from './store' 6 | import type { FlatStep } from './../../types' 7 | import { VSCodeButton, VSCodeDivider } from '@vscode/webview-ui-toolkit/react' 8 | 9 | interface JobsProps {} 10 | 11 | const STEP_STUBS = { 12 | http: { 13 | name: 'Fetch data', 14 | uses: 'githubocto/flat@v3', 15 | with: { 16 | http_url: '', 17 | }, 18 | }, 19 | sql: { 20 | name: 'Fetch data', 21 | uses: 'githubocto/flat@v3', 22 | with: { 23 | sql_connstring: '', 24 | sql_queryfile: '', 25 | }, 26 | }, 27 | } 28 | 29 | const Jobs: FunctionComponent = props => { 30 | const { state, update } = useFlatConfigStore() 31 | 32 | const handleJobAdded = (type: 'http' | 'sql') => { 33 | update(store => { 34 | // @ts-ignore 35 | store.state.jobs.scheduled.steps.push(STEP_STUBS[type]) 36 | }) 37 | } 38 | 39 | const steps = state.jobs.scheduled.steps 40 | .slice(2) 41 | .map((j, i) => ) 42 | 43 | return ( 44 |
45 |
46 |
47 |

48 | Where to get data from 49 |

50 |
51 |

52 | Flat can fetch data from HTTP endpoints or SQL queries. 53 |

54 |
55 |
56 |
{steps}
57 |
58 |
59 |
60 | Add {state.jobs.scheduled.steps.length ? 'another' : 'a'} data source 61 |
62 |
63 | handleJobAdded('http')} 66 | > 67 | Add from HTTP 68 | 69 | handleJobAdded('sql')} 72 | > 73 | 74 | Add from SQL 75 | 76 |
77 |
78 |
79 | ) 80 | } 81 | 82 | export default Jobs 83 | -------------------------------------------------------------------------------- /src/webviews/src/Setting.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | type SettingProps = { 4 | title: string 5 | } 6 | 7 | const Setting: FunctionComponent = props => { 8 | return ( 9 |
10 |
{props.title}
{' '} 11 |
12 | 13 |
14 |
15 | ) 16 | } 17 | 18 | export default Setting 19 | -------------------------------------------------------------------------------- /src/webviews/src/Step.tsx: -------------------------------------------------------------------------------- 1 | import { VSCodeButton, VSCodeTag } from '@vscode/webview-ui-toolkit/react' 2 | import React, { FunctionComponent } from 'react' 3 | import type { FlatStep } from '../../types' 4 | 5 | import { FilePicker } from './settings/FilePicker' 6 | import { StepConfig } from './StepConfig' 7 | import useFlatConfigStore from './store' 8 | 9 | type StepProps = { 10 | step: FlatStep 11 | index: number 12 | } 13 | 14 | export const Step: FunctionComponent = props => { 15 | const update = useFlatConfigStore(state => state.update) 16 | 17 | const handlePostprocessChange = (newPath?: string) => { 18 | update(store => { 19 | ;( 20 | store.state.jobs.scheduled.steps[props.index] as FlatStep 21 | ).with.postprocess = newPath 22 | }) 23 | } 24 | 25 | const handleRemoveStep = () => { 26 | update(store => { 27 | store.state.jobs.scheduled.steps.splice(props.index, 1) 28 | }) 29 | } 30 | 31 | return ( 32 |
33 |
34 | 35 | #{props.index + 1 - 2} Fetch data via{' '} 36 | {'with' in props.step && 'sql_queryfile' in props.step.with 37 | ? 'SQL' 38 | : 'HTTP'} 39 | 40 | 41 | 42 | Remove 43 | 44 |
45 |
46 | 47 | { 53 | handlePostprocessChange(newPath || undefined) 54 | }} 55 | isClearable 56 | /> 57 |
58 |
59 | ) 60 | } 61 | 62 | export default Step 63 | -------------------------------------------------------------------------------- /src/webviews/src/StepConfig.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { Step, FlatStep, PullHttpConfig, PullSqlConfig } from '../../types' 3 | import { FilePicker } from './settings/FilePicker' 4 | import { HttpEndpointPreview } from './settings/HttpEndpointPreview' 5 | import SecretInput from './settings/SecretInput' 6 | import useFlatConfigStore from './store' 7 | import { VSCodeTextField } from '@vscode/webview-ui-toolkit/react' 8 | 9 | interface StepConfigProps { 10 | step: Step 11 | stepIndex: number 12 | } 13 | 14 | export function StepConfig(props: StepConfigProps) { 15 | const { update, workspace } = useFlatConfigStore() 16 | 17 | const handleHttpValueChange = (stepName: string, newValue?: string) => { 18 | update(store => { 19 | const step = store.state.jobs.scheduled.steps[props.stepIndex] as FlatStep 20 | // @ts-ignore 21 | ;(step.with as PullHttpConfig)[stepName] = newValue 22 | }) 23 | } 24 | const handleSqlValueChange = (stepName: string, newValue: string) => { 25 | update(store => { 26 | const step = store.state.jobs.scheduled.steps[props.stepIndex] as FlatStep 27 | // @ts-ignore 28 | ;(step.with as PullSqlConfig)[stepName] = newValue 29 | }) 30 | } 31 | 32 | if ('with' in props.step && 'http_url' in props.step.with) { 33 | return ( 34 | <> 35 |
36 | { 44 | handleHttpValueChange( 45 | 'downloaded_filename', 46 | e.target.value || undefined 47 | ) 48 | } 49 | } 50 | > 51 | Downloaded Filename (required) 52 | 53 |

54 | The filename where you want the results to be saved. This file 55 | doesn't need to exist yet. 56 |

57 |
58 |
59 | { 65 | handleHttpValueChange('http_url', e.target.value) 66 | } 67 | } 68 | value={props.step.with.http_url} 69 | > 70 | Endpoint url (required) 71 | 72 |

73 | Which endpoint should we pull data from? This needs to be a stable, 74 | unchanging URL. 75 |

76 |
77 | 78 |
79 |
80 | 81 | ) 82 | } else if ('with' in props.step && 'sql_queryfile' in props.step.with) { 83 | return ( 84 | <> 85 |
86 | handleSqlValueChange('downloaded_filename', e.target.value) 94 | } 95 | > 96 | Downloaded filename (required) 97 | 98 |

99 | The filename (with a csv or json extension) where you want the 100 | results to be saved. This file doesn't need to exist yet. 101 |

102 |
103 | { 109 | handleSqlValueChange('sql_queryfile', newPath) 110 | }} 111 | /> 112 | 117 | handleSqlValueChange('sql_connstring', newValue) 118 | } 119 | /> 120 | 121 | ) 122 | } else { 123 | return null 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/webviews/src/Triggers.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import CronChooser from './settings/CronChooser' 3 | import useFlatConfigStore from './store' 4 | 5 | type TriggersProps = {} 6 | export function Triggers(props: TriggersProps) { 7 | const { state, update } = useFlatConfigStore() 8 | 9 | const cron = state.on?.schedule?.[0]?.cron || '' 10 | 11 | const handleScheduleChange = (schedule: string) => { 12 | update(store => { 13 | // not likely, but handling borked state 14 | if (Array.isArray(store.state.on)) { 15 | store.state.on = { schedule: [{ cron: '' }] } 16 | } 17 | if (!store.state.on?.schedule || !store.state.on?.schedule.length) { 18 | store.state.on.schedule = [{ cron: '' }] 19 | } 20 | store.state.on.schedule[0].cron = schedule 21 | }) 22 | } 23 | 24 | return ( 25 |
26 |
27 |

28 | When to update the data 29 |

30 |
31 | 32 |
33 | ) 34 | } 35 | 36 | export default Triggers 37 | -------------------------------------------------------------------------------- /src/webviews/src/VSCodeAPI.tsx: -------------------------------------------------------------------------------- 1 | declare const acquireVsCodeApi: Function 2 | 3 | interface VSCodeApi { 4 | getState: () => any 5 | setState: (newState: any) => any 6 | postMessage: (message: any) => void 7 | } 8 | 9 | // declare global { 10 | // interface Window { 11 | // acquireVsCodeApi(): VSCodeApi 12 | // } 13 | // } 14 | 15 | class VSCodeWrapper { 16 | private readonly vscodeApi: VSCodeApi = acquireVsCodeApi() 17 | 18 | /** 19 | * Send message to the extension framework. 20 | * @param message 21 | */ 22 | public postMessage(message: any): void { 23 | this.vscodeApi.postMessage(message) 24 | } 25 | 26 | /** 27 | * Add listener for messages from extension framework. 28 | * @param callback called when the extension sends a message 29 | * @returns function to clean up the message eventListener. 30 | */ 31 | public onMessage(callback: (message: any) => void): () => void { 32 | window.addEventListener('message', callback) 33 | return () => window.removeEventListener('message', callback) 34 | } 35 | } 36 | 37 | // Singleton to prevent multiple fetches of VsCodeAPI. 38 | export const VSCodeAPI: VSCodeWrapper = new VSCodeWrapper() 39 | -------------------------------------------------------------------------------- /src/webviews/src/Workflow.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | type WorkflowProps = {} 4 | 5 | const Workflow: FunctionComponent = props => { 6 | return
7 | } 8 | 9 | export default Workflow 10 | -------------------------------------------------------------------------------- /src/webviews/src/error-state.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { VSCodeButton } from '@vscode/webview-ui-toolkit/react' 3 | 4 | import { VSCodeAPI } from './VSCodeAPI' 5 | 6 | export function ErrorState() { 7 | const [isRefreshing, setIsRefreshing] = useState(false) 8 | 9 | const handleRetry = () => { 10 | setIsRefreshing(true) 11 | VSCodeAPI.postMessage({ 12 | type: 'refreshGitDetails', 13 | }) 14 | } 15 | 16 | return ( 17 |
18 |

19 | Please ensure you're working out of a Git repository with a valid 20 | upstream remote set. 21 |

22 |
23 | 24 | {isRefreshing ? 'Refreshing...' : 'Refresh'} 25 | 26 |
27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/webviews/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | import { useFlatConfigStore } from './store' 5 | import './vscode.css' 6 | import { VSCodeAPI } from './VSCodeAPI' 7 | 8 | // TODO: Type the incoming config data 9 | let config: any = {} 10 | let workspace = '' 11 | let gitRepo = '' 12 | 13 | const root = document.getElementById('root') 14 | 15 | function transformConfig(config: any) { 16 | if (!config) 17 | config = { 18 | on: { 19 | workflow_dispatch: {}, 20 | push: { 21 | paths: ['.github/workflows/flat.yml'], 22 | }, 23 | }, 24 | } 25 | if (!config.on) config.on = {} 26 | if (!config.on.push) config.on.push = {} 27 | if (!config.on.push.paths) config.on.push.paths = [] 28 | if (!config.jobs) config.jobs = {} 29 | if (!config.jobs.scheduled) 30 | config.jobs.scheduled = { 31 | 'runs-on': 'ubuntu-latest', 32 | steps: [ 33 | { 34 | name: 'Setup deno', 35 | uses: 'denoland/setup-deno@main', 36 | with: { 37 | 'deno-version': 'v1.10.x', 38 | }, 39 | }, 40 | { 41 | name: 'Check out repo', 42 | uses: 'actions/checkout@v2', 43 | }, 44 | ], 45 | } 46 | 47 | return config 48 | } 49 | 50 | config = transformConfig(config) 51 | 52 | if (root) { 53 | workspace = root.getAttribute('data-workspace') || '' 54 | gitRepo = root.getAttribute('data-gitrepo') || '' 55 | } 56 | 57 | useFlatConfigStore.setState({ 58 | // @ts-ignore 59 | state: config, 60 | workspace, 61 | gitRepo, 62 | isStubData: true, 63 | }) 64 | 65 | VSCodeAPI.postMessage({ 66 | type: 'refreshFiles', 67 | }) 68 | 69 | VSCodeAPI.postMessage({ 70 | type: 'refreshState', 71 | }) 72 | 73 | window.addEventListener('message', e => { 74 | // @ts-ignore 75 | const message = e.data 76 | if (message.command === 'updateState') { 77 | useFlatConfigStore.setState({ 78 | state: transformConfig(message.config), 79 | isStubData: false, 80 | }) 81 | } else if (message.command === 'updateFiles') { 82 | useFlatConfigStore.setState({ 83 | files: message.files, 84 | }) 85 | } 86 | }) 87 | 88 | ReactDOM.render( 89 | 90 | 91 | , 92 | document.getElementById('root') 93 | ) 94 | -------------------------------------------------------------------------------- /src/webviews/src/settings/CronChooser.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useEffect } from 'react' 2 | import { ErrorBoundary } from 'react-error-boundary' 3 | import { 4 | VSCodeRadioGroup, 5 | VSCodeRadio, 6 | VSCodeTextField, 7 | VSCodeLink, 8 | } from '@vscode/webview-ui-toolkit/react' 9 | import { RadioGroupOrientation } from '@vscode/webview-ui-toolkit' 10 | import * as cronstrue from 'cronstrue' 11 | import { useState } from 'react' 12 | 13 | type CronChooserProps = { 14 | value: string 15 | onChange: (schedule: string) => void 16 | } 17 | 18 | const defaultSchedules = { 19 | fiveMinutes: '*/5 * * * *', 20 | hour: '0 * * * *', 21 | day: '0 0 * * *', 22 | } 23 | 24 | const CronFallback = ({ error }: { error: any }) => { 25 | return ( 26 |
27 | 28 | {error} 29 |
30 | ) 31 | } 32 | 33 | const ValidateCron = ({ value }: { value: string }) => { 34 | const feedback = cronstrue.toString(value) 35 | return ( 36 |
37 | 38 | 39 | Will run:{' '} 40 | {feedback === 'Every minute' ? 'Every five minutes' : feedback} 41 | 42 |
43 | ) 44 | } 45 | 46 | const determineInitialCustomValue = (schedule?: string) => { 47 | if (!schedule) return false 48 | return !Object.values(defaultSchedules).includes(schedule) 49 | } 50 | 51 | const CustomCronTextField = ({ 52 | value, 53 | onChange, 54 | }: { 55 | value: string 56 | onChange: (val: string) => void 57 | }) => { 58 | const [localValue, setValue] = useState(value) 59 | 60 | return ( 61 |
62 | { 67 | setValue(e.target.value) 68 | onChange(e.target.value) 69 | } 70 | } 71 | placeholder="Enter custom schedule" 72 | > 73 | Enter a custom CRON schedule 74 | 75 | 76 | 77 | 78 |
79 | 80 | Need help with CRON syntax? 81 | 82 |
83 |
84 | ) 85 | } 86 | 87 | const CronChooser: FunctionComponent = props => { 88 | const [showCustom, setShowCustom] = React.useState(() => { 89 | return determineInitialCustomValue(props.value) 90 | }) 91 | 92 | const handleRadioChange = (value: string) => { 93 | const custom = value === 'custom' 94 | setShowCustom(custom) 95 | if (!custom) { 96 | props.onChange(value) 97 | } 98 | } 99 | 100 | useEffect(() => { 101 | if (!props.value) return 102 | const isCustom = determineInitialCustomValue(props.value) 103 | if (isCustom && !showCustom) { 104 | setShowCustom(true) 105 | } 106 | }, [props.value]) 107 | 108 | return ( 109 |
110 | 111 | 112 | handleRadioChange(e.target.value) 116 | } 117 | checked={!showCustom && props.value === defaultSchedules.fiveMinutes} 118 | value={defaultSchedules.fiveMinutes} 119 | > 120 | Every five minutes 121 | 122 | handleRadioChange(e.target.value) 126 | } 127 | checked={!showCustom && props.value === defaultSchedules.hour} 128 | value={defaultSchedules.hour} 129 | > 130 | Every hour 131 | 132 | handleRadioChange(e.target.value) 137 | } 138 | checked={!showCustom && props.value === defaultSchedules.day} 139 | value={defaultSchedules.day} 140 | > 141 | Every day 142 | 143 | handleRadioChange(e.target.value) 147 | } 148 | checked={showCustom} 149 | value="custom" 150 | > 151 | Custom 152 | 153 | 154 | {showCustom && ( 155 |
156 | 157 |
158 | )} 159 |
160 | ) 161 | } 162 | 163 | export default CronChooser 164 | -------------------------------------------------------------------------------- /src/webviews/src/settings/FieldWithDescription.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | type FieldWithDescriptionProps = { 4 | title: string 5 | } 6 | 7 | const FieldWithDescription: FunctionComponent = props => { 8 | return ( 9 |
10 |
11 | {props.title} 12 |
13 | {props.children} 14 |
15 | ) 16 | } 17 | 18 | export default FieldWithDescription 19 | -------------------------------------------------------------------------------- /src/webviews/src/settings/FilePicker.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import React from 'react' 3 | 4 | import { 5 | Combobox, 6 | ComboboxInput, 7 | ComboboxPopover, 8 | ComboboxList, 9 | ComboboxOption, 10 | } from '@reach/combobox' 11 | import '@reach/combobox/styles.css' 12 | 13 | import { FilePreview } from './FilePreview' 14 | import { VSCodeAPI } from '../VSCodeAPI' 15 | import useFlatConfigStore from '../store' 16 | import { 17 | VSCodeButton, 18 | VSCodeOption, 19 | VSCodeTextField, 20 | } from '@vscode/webview-ui-toolkit/react' 21 | import { useEffect } from 'react' 22 | 23 | interface FilePickerProps { 24 | value?: string 25 | onChange: (newValue: string) => void 26 | title: string 27 | label: string 28 | accept?: string 29 | isClearable?: boolean 30 | } 31 | 32 | export function FilePicker(props: FilePickerProps) { 33 | const { label, value, onChange, accept = '', title } = props 34 | const [localValue, setLocalValue] = React.useState(value) 35 | const files = useFlatConfigStore(state => state.files || []) 36 | 37 | useEffect(() => { 38 | if (localValue === '' && Boolean(value)) { 39 | onChange(null) 40 | } 41 | }, [value, localValue]) 42 | 43 | const acceptedExtensions = accept.split(',') 44 | 45 | const filteredFiles = files 46 | .filter(file => { 47 | return ( 48 | !acceptedExtensions.length || 49 | acceptedExtensions.includes(`.${file.split('.').slice(-1)}`) 50 | ) 51 | }) 52 | .filter(file => { 53 | if (!localValue) return true 54 | return file.includes(localValue) 55 | }) 56 | 57 | const handleFocus = () => { 58 | VSCodeAPI.postMessage({ 59 | type: 'refreshFiles', 60 | }) 61 | } 62 | 63 | const handlePreview = (path: string) => { 64 | VSCodeAPI.postMessage({ 65 | type: 'previewFile', 66 | data: path, 67 | }) 68 | } 69 | 70 | return ( 71 |
72 | { 76 | setLocalValue(value) 77 | onChange(value) 78 | }} 79 | > 80 | { 85 | setLocalValue(e.target.value) 86 | }} 87 | className="w-full" 88 | as={VSCodeTextField} 89 | placeholder={files.length === 0 ? 'Loading files...' : 'Pick a file'} 90 | > 91 | {title} 92 | 93 | 94 | 95 | {!files &&
Loading...
} 96 | {filteredFiles.length > 0 && ( 97 | 98 | {filteredFiles.map(file => { 99 | return ( 100 | 106 | ) 107 | })} 108 | 109 | )} 110 | {!filteredFiles.length && ( 111 |
112 | No files found 113 | {accept && ` with the extensions ${accept}`} 114 | {localValue && ` that include "${localValue}"`} 115 |
116 | )} 117 |
118 |
119 |
120 |

121 | {label} 122 |

123 |
124 | {files.includes(localValue) && ( 125 |
126 | 127 |
128 | { 131 | handlePreview(localValue) 132 | }} 133 | > 134 | 135 | View file 136 | 137 |
138 |
139 | )} 140 |
141 | ) 142 | } 143 | -------------------------------------------------------------------------------- /src/webviews/src/settings/FilePreview.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { VSCodeAPI } from '../VSCodeAPI' 3 | 4 | type FilePreviewProps = { 5 | file: string 6 | } 7 | 8 | export const FilePreview: FunctionComponent = props => { 9 | const { file } = props 10 | const fileRef = React.useRef('') 11 | const [fileContent, setFileContent] = React.useState(undefined) 12 | 13 | React.useEffect(() => { 14 | setFileContent(undefined) 15 | fileRef.current = file 16 | VSCodeAPI.postMessage({ 17 | type: 'getFileContents', 18 | data: file, 19 | }) 20 | }, [file]) 21 | 22 | React.useEffect(() => { 23 | window.addEventListener('message', e => { 24 | const message = e.data 25 | if (message.command !== 'returnFileContents') return 26 | if (message.file !== fileRef.current) return 27 | setFileContent(message.contents) 28 | }) 29 | }, []) 30 | 31 | if (!fileContent) return null 32 | 33 | return ( 34 |
35 |       {(fileContent || '').slice(0, 100000)}
36 |     
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/webviews/src/settings/HttpEndpointPreview.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { VSCodeAPI } from '../VSCodeAPI' 3 | import { debounce } from 'ts-debounce' 4 | 5 | type HttpEndpointPreviewProps = { 6 | url: string 7 | } 8 | 9 | export const HttpEndpointPreview: FunctionComponent = props => { 10 | const { url } = props 11 | const urlRef = React.useRef('') 12 | const [fileContent, setFileContent] = React.useState(undefined) 13 | 14 | const fetchData = async () => { 15 | VSCodeAPI.postMessage({ 16 | type: 'getUrlContents', 17 | data: urlRef.current, 18 | }) 19 | } 20 | const debounceFetchData = React.useCallback(debounce(fetchData, 600), []) 21 | 22 | React.useEffect(() => { 23 | urlRef.current = url 24 | setFileContent(undefined) 25 | debounceFetchData() 26 | }, [url]) 27 | 28 | React.useEffect(() => { 29 | window.addEventListener('message', e => { 30 | const message = e.data 31 | if (message.command !== 'returnUrlContents') return 32 | if (message.url !== urlRef.current) return 33 | setFileContent(message.contents) 34 | }) 35 | }, []) 36 | 37 | if (!fileContent) return null 38 | 39 | return ( 40 |
41 |       {(fileContent || '').slice(0, 100000)}
42 |     
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/webviews/src/settings/SecretInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { VSCodeAPI } from './../VSCodeAPI' 3 | import { VSCodeButton, VSCodeTextField } from '@vscode/webview-ui-toolkit/react' 4 | import { customAlphabet } from 'nanoid' 5 | const nanoid = customAlphabet('1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ', 6) 6 | 7 | type SecretInputProps = { 8 | title: string 9 | label: string 10 | value: string 11 | handleChange: (newValue: string) => void 12 | } 13 | 14 | const SecretInput: FunctionComponent = props => { 15 | const { title, label, value, handleChange } = props 16 | 17 | const [localValue, setLocalValue] = React.useState(value) 18 | const [isDirty, setIsDirty] = React.useState(false) 19 | const [didError, setDidError] = React.useState(false) 20 | const [doesExist, setDoesExist] = React.useState( 21 | value && 22 | (value.split(' ')[1] || '').split('_')[0] === 'secrets.FLAT' && 23 | (value.split(' ')[1] || '').split('_')[2] === 'CONNSTRING' 24 | ) 25 | 26 | const fieldName = React.useMemo( 27 | () => (doesExist ? value : `\${{ secrets.FLAT_${nanoid()}_CONNSTRING }}`), 28 | [] 29 | ) 30 | const innerFieldName = fieldName.split(' ')[1].replace('secrets.', '') 31 | const isSavedAsSecret = 32 | fieldName.split('_')[0] === '${{ secrets.FLAT' && 33 | fieldName.split('_')[2] === 'CONNSTRING }}' && 34 | localValue === fieldName 35 | 36 | const handleSave = async () => { 37 | VSCodeAPI.postMessage({ 38 | type: 'storeSecret', 39 | data: { fieldName: innerFieldName, value: localValue }, 40 | }) 41 | } 42 | 43 | React.useEffect(() => { 44 | window.addEventListener('message', e => { 45 | // @ts-ignore 46 | const message = e.data 47 | if (message.command !== 'storeSecretResponse') return 48 | if (message.fieldName !== innerFieldName) return 49 | 50 | if (message.status === 'success') { 51 | handleChange(fieldName) 52 | setIsDirty(false) 53 | setDoesExist(true) 54 | setLocalValue(fieldName) 55 | } else { 56 | setDidError(true) 57 | } 58 | }) 59 | }, []) 60 | return ( 61 |
62 |
{ 64 | e.preventDefault() 65 | handleSave() 66 | }} 67 | > 68 |
69 |
70 | { 77 | setLocalValue(e.target.value) 78 | setIsDirty(true) 79 | setDidError(false) 80 | } 81 | } 82 | > 83 | {title} 84 | 85 |
86 |
87 | 92 | {isSavedAsSecret ? ( 93 | <> 94 | Saved successfully 95 | 96 | 97 | ) : ( 98 | 'Save as secret' 99 | )} 100 | 101 |
102 |
103 |
104 |

105 | {label} 106 |

107 | {didError && ( 108 |

109 | Something went wrong, please try again. 110 |

111 | )} 112 |
113 | ) 114 | } 115 | 116 | export default SecretInput 117 | -------------------------------------------------------------------------------- /src/webviews/src/store.ts: -------------------------------------------------------------------------------- 1 | import produce, { Draft } from 'immer' 2 | import create, { State, StateCreator } from 'zustand' 3 | import type { FlatState } from '../../types' 4 | 5 | const immer = ( 6 | config: StateCreator) => void) => void> 7 | ): StateCreator => (set, get, api) => 8 | config(fn => set(produce(fn)), get, api) 9 | 10 | interface ValidationError { 11 | path: string 12 | errors: string[] 13 | message: string 14 | type: string 15 | } 16 | 17 | type FlatStoreState = { 18 | state: FlatState 19 | workspace: string 20 | gitRepo: string 21 | errors: ValidationError[] 22 | update: (fn: (draft: Draft) => void) => void 23 | setErrors: (errors: ValidationError[]) => void 24 | files?: string[] 25 | isStubData: boolean 26 | } 27 | 28 | export const useFlatConfigStore = create( 29 | immer(set => ({ 30 | errors: [], 31 | workspace: '', 32 | gitRepo: '', 33 | isStubData: true, 34 | state: { 35 | name: 'Flat', 36 | on: { 37 | workflow_dispatch: undefined, 38 | push: { 39 | paths: ['.github/workflows/flat.yml'], 40 | }, 41 | schedule: [ 42 | { 43 | cron: '0 0 * * *', 44 | }, 45 | ], 46 | }, 47 | jobs: { 48 | scheduled: { 49 | steps: [ 50 | { 51 | name: 'Setup deno', 52 | uses: 'denoland/setup-deno@main', 53 | with: { 54 | 'deno-version': 'v1.10.x', 55 | }, 56 | }, 57 | { 58 | name: 'Check out repo', 59 | uses: 'actions/checkout@v2', 60 | }, 61 | ], 62 | }, 63 | }, 64 | }, 65 | update: fn => { 66 | set(fn) 67 | }, 68 | setErrors: errors => { 69 | set(draft => { 70 | draft.errors = errors 71 | }) 72 | }, 73 | files: undefined, 74 | })) 75 | ) 76 | 77 | export default useFlatConfigStore 78 | -------------------------------------------------------------------------------- /src/webviews/src/validation.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import * as yup from 'yup' 3 | 4 | yup.addMethod(yup.array, 'unique', function (message) { 5 | return this.test('unique', message, function (list) { 6 | const mapper = x => x.name 7 | const set = [...new Set(list.map(mapper))] 8 | const isUnique = list.length === set.length 9 | if (isUnique) { 10 | return true 11 | } 12 | const idx = list.findIndex((l, i) => mapper(l) !== set[i]) 13 | return this.createError({ 14 | path: `jobs[${idx}].name`, 15 | message: message, 16 | }) 17 | }) 18 | }) 19 | 20 | const jobValidationSchema = yup.object().shape({ 21 | name: yup 22 | .string() 23 | .required('Please enter a job name') 24 | .matches( 25 | /^([a-zA-Z_]){1}([a-zA-Z_\-\d])*$/, 26 | "Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'" 27 | ), 28 | }) 29 | 30 | export const flatStateValidationSchema = yup.object().shape({ 31 | // on: yup.shape({ 32 | // push: yup.optional( 33 | // yup.shape({ 34 | // branches: yup.array(), 35 | // }) 36 | // ), 37 | // schedule: yup.shape({ 38 | // cron: yup.string().required('Please provide a trigger schedule'), 39 | // }), 40 | // }), 41 | jobs: yup 42 | .array() 43 | .of(jobValidationSchema) 44 | // @ts-ignore 45 | .unique('Job names must be unique') 46 | .required(), 47 | }) 48 | -------------------------------------------------------------------------------- /src/webviews/src/vscode.css: -------------------------------------------------------------------------------- 1 | /* @tailwind base; */ 2 | 3 | body { 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | @tailwind components; 9 | @tailwind utilities; 10 | 11 | [data-reach-combobox-option][aria-selected='true'] { 12 | background: var(--list-active-selection-background) !important; 13 | border: calc(var(--border-width) * 1px) solid var(--focus-border) !important; 14 | color: var(--list-active-selection-foreground) !important; 15 | } 16 | 17 | [data-reach-combobox-option]:hover { 18 | background: var(--list-active-selection-background) !important; 19 | } 20 | 21 | [data-reach-combobox-option][aria-selected='true']:hover { 22 | background: var(--list-active-selection-background) !important; 23 | } 24 | -------------------------------------------------------------------------------- /src/webviews/types/static.d.ts: -------------------------------------------------------------------------------- 1 | /* Use this file to declare any custom file extensions for importing */ 2 | /* Use this folder to also add/extend a package d.ts file, if needed. */ 3 | 4 | /* CSS MODULES */ 5 | declare module '*.module.css' { 6 | const classes: { [key: string]: string }; 7 | export default classes; 8 | } 9 | declare module '*.module.scss' { 10 | const classes: { [key: string]: string }; 11 | export default classes; 12 | } 13 | declare module '*.module.sass' { 14 | const classes: { [key: string]: string }; 15 | export default classes; 16 | } 17 | declare module '*.module.less' { 18 | const classes: { [key: string]: string }; 19 | export default classes; 20 | } 21 | declare module '*.module.styl' { 22 | const classes: { [key: string]: string }; 23 | export default classes; 24 | } 25 | 26 | /* CSS */ 27 | declare module '*.css'; 28 | declare module '*.scss'; 29 | declare module '*.sass'; 30 | declare module '*.less'; 31 | declare module '*.styl'; 32 | 33 | /* IMAGES */ 34 | declare module '*.svg' { 35 | const ref: string; 36 | export default ref; 37 | } 38 | declare module '*.bmp' { 39 | const ref: string; 40 | export default ref; 41 | } 42 | declare module '*.gif' { 43 | const ref: string; 44 | export default ref; 45 | } 46 | declare module '*.jpg' { 47 | const ref: string; 48 | export default ref; 49 | } 50 | declare module '*.jpeg' { 51 | const ref: string; 52 | export default ref; 53 | } 54 | declare module '*.png' { 55 | const ref: string; 56 | export default ref; 57 | } 58 | 59 | /* CUSTOM: ADD YOUR OWN HERE */ 60 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("@types/tailwindcss/tailwind-config").TailwindConfig } */ 2 | module.exports = { 3 | content: ['./src/webviews/**/*.{js,jsx,ts,tsx,css}'], 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig-webview.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/webviews"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "node", 7 | "jsx": "preserve", 8 | "baseUrl": "./src/webviews", 9 | /* paths - import rewriting/resolving */ 10 | "paths": { 11 | // If you configured any Snowpack aliases, add them here. 12 | // Add this line to get types for streaming imports (packageOptions.source="remote"): 13 | // "*": [".snowpack/types/*"] 14 | // More info: https://www.snowpack.dev/guides/streaming-imports 15 | }, 16 | /* noEmit - Snowpack builds (emits) files, not tsc. */ 17 | "noEmit": true, 18 | /* Additional Options */ 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "resolveJsonModule": true, 23 | "allowSyntheticDefaultImports": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "lib": ["ES2020"], 6 | "outDir": "out", 7 | "sourceMap": true, 8 | "strict": true, 9 | "rootDir": "src", 10 | "skipLibCheck": true 11 | }, 12 | "exclude": ["node_modules", ".vscode-test", "src/webviews"] 13 | } 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | target: 'node', 5 | mode: 'development', 6 | entry: './src/extension.ts', 7 | output: { 8 | filename: 'extension.js', 9 | path: path.resolve(__dirname, 'out'), 10 | libraryTarget: 'commonjs2', 11 | devtoolModuleFilenameTemplate: '../[resource-path]', 12 | }, 13 | devtool: 'source-map', 14 | externals: { 15 | vscode: 'commonjs vscode', 16 | }, 17 | resolve: { 18 | // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader 19 | extensions: ['.ts', '.js'], 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.ts$/, 25 | exclude: /node_modules/, 26 | use: [ 27 | { 28 | loader: 'babel-loader', 29 | }, 30 | ], 31 | }, 32 | ], 33 | }, 34 | } 35 | --------------------------------------------------------------------------------