{
42 | const api = await getApi();
43 |
44 | const files = gistFiles.reduce((accumulator, [filename, file]) => {
45 | if (file && file.content === "") {
46 | file.content = ZERO_WIDTH_SPACE;
47 | }
48 |
49 | return {
50 | ...accumulator,
51 | [filename]: file
52 | };
53 | }, {});
54 |
55 | const { body } = await api.edit(id, { files });
56 |
57 | const gist =
58 | store.dailyNotes.gist && store.dailyNotes.gist.id === id
59 | ? store.dailyNotes.gist
60 | : (store.gists.find((gist) => gist.id === id) ||
61 | store.archivedGists.find((gist) => gist.id === id))!;
62 |
63 | runInAction(() => {
64 | gist.files = body.files;
65 | gist.updated_at = body.updated_at;
66 | gist.history = body.history;
67 | });
68 |
69 | return gist;
70 | }
--------------------------------------------------------------------------------
/images/icon-activity.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/repos/wiki/markdownPreview.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import { RepoFileSystemProvider } from "../fileSystem";
3 | import { getTreeItemFromLink, getUriFromLink } from "./utils";
4 |
5 | export function extendMarkdownIt(md: any) {
6 | return md
7 | .use(require("markdown-it-regex").default, {
8 | name: "gistpad-links",
9 | regex: /(? {
11 | if (
12 | !RepoFileSystemProvider.isRepoDocument(
13 | vscode.window.activeTextEditor!.document
14 | )
15 | ) {
16 | return;
17 | }
18 |
19 | const [repo] = RepoFileSystemProvider.getRepoInfo(
20 | vscode.window.activeTextEditor!.document.uri
21 | )!;
22 | if (!repo.isWiki) {
23 | return;
24 | }
25 |
26 | const linkUri = getUriFromLink(repo, link);
27 | const args = encodeURIComponent(JSON.stringify([linkUri]));
28 | const href = `command:vscode.open?${args}`;
29 |
30 | return `[[${link}]]`;
31 | }
32 | })
33 | .use(require("markdown-it-regex").default, {
34 | name: "gistpad-embeds",
35 | regex: /(?:\!\[\[)([^\]]+?)(?:\]\])/,
36 | replace: (link: string) => {
37 | if (
38 | !RepoFileSystemProvider.isRepoDocument(
39 | vscode.window.activeTextEditor!.document
40 | )
41 | ) {
42 | return;
43 | }
44 |
45 | const [repo] = RepoFileSystemProvider.getRepoInfo(
46 | vscode.window.activeTextEditor!.document.uri
47 | )!;
48 | if (!repo.isWiki) {
49 | return;
50 | }
51 |
52 | const treeItem = getTreeItemFromLink(repo, link);
53 | if (treeItem) {
54 | const markdown = require("markdown-it")();
55 | markdown.renderer.rules.heading_open = (
56 | tokens: any,
57 | index: number,
58 | options: any,
59 | env: any,
60 | self: any
61 | ) => {
62 | tokens[index].attrSet("style", "text-align: center; border: 0; margin: 10px 0 5px 0");
63 | return self.renderToken(tokens, index, options, env, self);
64 | };
65 |
66 | const htmlContent = markdown.render(treeItem.contents);
67 | return `
68 |
69 | ${htmlContent}
70 |
71 | `;
72 | }
73 | }
74 | });
75 | }
76 |
--------------------------------------------------------------------------------
/src/repos/wiki/linkProvider.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CancellationToken,
3 | commands,
4 | DocumentLink,
5 | DocumentLinkProvider,
6 | languages,
7 | Range,
8 | TextDocument,
9 | Uri
10 | } from "vscode";
11 | import { EXTENSION_NAME } from "../../constants";
12 | import { withProgress } from "../../utils";
13 | import { RepoFileSystemProvider } from "../fileSystem";
14 | import { Repository } from "../store";
15 | import {
16 | findLinks,
17 | getPageFilePath,
18 | getTreeItemFromLink,
19 | getUriFromLink,
20 | LINK_SELECTOR
21 | } from "./utils";
22 |
23 | class WikiDocumentLink extends DocumentLink {
24 | constructor(
25 | public repo: Repository,
26 | public title: string,
27 | range: Range,
28 | target?: Uri
29 | ) {
30 | super(range, target);
31 | }
32 | }
33 |
34 | class WikiDocumentLinkProvider implements DocumentLinkProvider {
35 | public provideDocumentLinks(
36 | document: TextDocument
37 | ): WikiDocumentLink[] | undefined {
38 | const [repo] = RepoFileSystemProvider.getRepoInfo(document.uri)!;
39 | if (!repo.isWiki) {
40 | return;
41 | }
42 |
43 | const links = findLinks(document.getText());
44 | if (!links) {
45 | return;
46 | }
47 | const documentLinks = [...links];
48 | return documentLinks.map(({ title, contentStart, contentEnd }) => {
49 | const linkRange = new Range(
50 | document.positionAt(contentStart),
51 | document.positionAt(contentEnd)
52 | );
53 |
54 | const treeItem = getTreeItemFromLink(repo, title);
55 | const linkUri = treeItem ? getUriFromLink(repo, title) : undefined;
56 |
57 | return new WikiDocumentLink(repo, title, linkRange, linkUri);
58 | });
59 | }
60 |
61 | async resolveDocumentLink(link: WikiDocumentLink, token: CancellationToken) {
62 | const filePath = getPageFilePath(link.title);
63 | link.target = RepoFileSystemProvider.getFileUri(link.repo.name, filePath);
64 |
65 | const treeItem = getTreeItemFromLink(link.repo, link.title);
66 | if (!treeItem) {
67 | await withProgress("Creating page...", async () =>
68 | commands.executeCommand(
69 | `${EXTENSION_NAME}._createWikiPage`,
70 | link.repo,
71 | link.title
72 | )
73 | );
74 | }
75 |
76 | return link;
77 | }
78 | }
79 |
80 | export function registerDocumentLinkProvider() {
81 | languages.registerDocumentLinkProvider(
82 | LINK_SELECTOR,
83 | new WikiDocumentLinkProvider()
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/images/dark/notebook.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/images/light/notebook.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/abstractions/node/images/pasteImage.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import * as config from "../../../config";
3 | import { pasteImageAsBase64 } from "./pasteImageAsBase64";
4 | import { pasteImageAsFile } from "./pasteImageAsFile";
5 | import { createUploadMarkup } from "./utils/createUploadMarkup";
6 | import { randomInt } from "./utils/randomInt";
7 |
8 | export const DocumentLanguages = {
9 | html: "html",
10 | markdown: "markdown",
11 | pug: "jade"
12 | };
13 |
14 | async function addUploadingMarkup(
15 | editor: vscode.TextEditor,
16 | id: string | number,
17 | isFilePaste: boolean
18 | ) {
19 | const markup = createUploadMarkup(
20 | id,
21 | isFilePaste,
22 | editor.document.languageId
23 | );
24 |
25 | await editor.edit((edit) => {
26 | const current = editor.selection;
27 |
28 | if (current.isEmpty) {
29 | edit.insert(current.start, markup);
30 | } else {
31 | edit.replace(current, markup);
32 | }
33 | });
34 | }
35 |
36 | async function tryToRemoveUploadingMarkup(
37 | editor: vscode.TextEditor,
38 | id: string | number,
39 | isUploadAsFile: boolean
40 | ) {
41 | try {
42 | const markup = createUploadMarkup(
43 | id,
44 | isUploadAsFile,
45 | editor.document.languageId
46 | );
47 |
48 | editor.edit((edit) => {
49 | const { document } = editor;
50 | const text = document.getText();
51 |
52 | const index = text.indexOf(markup);
53 | if (index === -1) {
54 | throw new Error("No upload markup is found.");
55 | }
56 |
57 | const startPos = document.positionAt(index);
58 | const endPos = document.positionAt(index + markup.length);
59 | const range = new vscode.Selection(startPos, endPos);
60 |
61 | edit.replace(range, "");
62 | });
63 | } catch {}
64 | }
65 |
66 | export async function pasteImageCommand(editor: vscode.TextEditor) {
67 | const imageType = config.get("images.pasteType");
68 | const isFilePaste = imageType === "file";
69 |
70 | const imageId = randomInt();
71 | const addUploadingMarkupPromise = addUploadingMarkup(
72 | editor,
73 | imageId,
74 | isFilePaste
75 | );
76 |
77 | try {
78 | if (!isFilePaste) {
79 | return await pasteImageAsBase64(editor, imageId);
80 | }
81 | return await pasteImageAsFile(editor, imageId);
82 | } catch (e) {
83 | vscode.window.showErrorMessage(
84 | "There doesn't appear to be an image on your clipboard. Copy an image and try again."
85 | );
86 | } finally {
87 | await addUploadingMarkupPromise;
88 | await tryToRemoveUploadingMarkup(editor, imageId, isFilePaste);
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/repos/wiki/actions.ts:
--------------------------------------------------------------------------------
1 | import { runInAction } from "mobx";
2 | import { Location, Range, Uri, workspace } from "vscode";
3 | import { byteArrayToString } from "../../utils";
4 | import { RepoFileSystemProvider } from "../fileSystem";
5 | import { Repository, Tree, TreeItem } from "../store";
6 | import { getRepoFile } from "../store/actions";
7 | import { findLinks, getTreeItemFromLink, isWiki } from "./utils";
8 |
9 | async function getBackLinks(uri: Uri, contents: string) {
10 | const documentLinks = [...findLinks(contents)];
11 | return Promise.all(
12 | documentLinks.map(async ({ title, contentStart, contentEnd }) => {
13 | const document = await workspace.openTextDocument(uri);
14 | const start = document.positionAt(contentStart);
15 | const end = document.positionAt(contentEnd);
16 |
17 | return {
18 | title,
19 | linePreview: document.lineAt(start).text,
20 | location: new Location(uri, new Range(start, end))
21 | };
22 | })
23 | );
24 | }
25 |
26 | export async function updateTree(repo: Repository, tree: Tree) {
27 | if (!isWiki(repo, tree)) {
28 | repo.tree = tree;
29 | return;
30 | }
31 |
32 | const markdownFiles = tree.tree.filter((treeItem) =>
33 | treeItem.path.endsWith(".md")
34 | );
35 |
36 | const documents = await Promise.all(
37 | markdownFiles.map(
38 | async (treeItem): Promise => {
39 | const contents = byteArrayToString(
40 | await getRepoFile(repo.name, treeItem.sha)
41 | );
42 | treeItem.contents = contents;
43 |
44 | const match = contents!.match(/^(?:#+\s*)(.+)$/m);
45 | const displayName = match ? match[1].trim() : undefined;
46 | treeItem.displayName = displayName;
47 |
48 | return treeItem;
49 | }
50 | )
51 | );
52 |
53 | repo.tree = tree;
54 | repo.isLoading = false;
55 |
56 | for (let { path, contents } of documents) {
57 | const uri = RepoFileSystemProvider.getFileUri(repo.name, path);
58 | const links = await getBackLinks(uri, contents!);
59 | for (const link of links) {
60 | const item = getTreeItemFromLink(repo, link.title);
61 | if (item) {
62 | const entry = documents.find((doc) => doc.path === item.path)!;
63 | if (entry.backLinks) {
64 | entry.backLinks.push(link);
65 | } else {
66 | entry.backLinks = [link];
67 | }
68 | }
69 | }
70 | }
71 |
72 | runInAction(() => {
73 | for (let { path, backLinks } of documents) {
74 | const item = repo.tree?.tree.find((item) => item.path === path);
75 | item!.backLinks = backLinks;
76 | }
77 | });
78 | }
79 |
--------------------------------------------------------------------------------
/src/repos/comments/commands.ts:
--------------------------------------------------------------------------------
1 | import {
2 | commands,
3 | CommentMode,
4 | CommentReply,
5 | ExtensionContext,
6 | MarkdownString
7 | } from "vscode";
8 | import { RepoCommitComment } from ".";
9 | import { EXTENSION_NAME } from "../../constants";
10 | import { getCurrentUser } from "../../store/auth";
11 | import { RepoFileSystemProvider } from "../fileSystem";
12 | import { store } from "../store";
13 | import {
14 | createRepoComment,
15 | deleteRepoComment,
16 | editRepoComment
17 | } from "./actions";
18 |
19 | function updateComments(comment: RepoCommitComment, mode: CommentMode) {
20 | comment.parent.comments = comment.parent.comments.map((c) => {
21 | if ((c as RepoCommitComment).id === comment.id) {
22 | c.mode = mode;
23 | }
24 |
25 | return c;
26 | });
27 | }
28 |
29 | export function registerCommentCommands(context: ExtensionContext) {
30 | context.subscriptions.push(
31 | commands.registerCommand(
32 | `${EXTENSION_NAME}.addRepositoryComment`,
33 | async ({ text, thread }: CommentReply) => {
34 | const [repo, path] = RepoFileSystemProvider.getFileInfo(thread.uri)!;
35 | const repository = store.repos.find((r) => r.name === repo);
36 |
37 | const comment = await createRepoComment(
38 | repo,
39 | path,
40 | text,
41 | thread.range!.start.line + 1
42 | );
43 |
44 | repository?.comments.push(comment);
45 |
46 | const newComment = new RepoCommitComment(
47 | comment,
48 | repo,
49 | thread,
50 | getCurrentUser()
51 | );
52 |
53 | thread.comments = [newComment];
54 | }
55 | )
56 | );
57 |
58 | context.subscriptions.push(
59 | commands.registerCommand(
60 | `${EXTENSION_NAME}.deleteRepositoryComment`,
61 | async (comment: RepoCommitComment) => {
62 | await deleteRepoComment(comment.repo, comment.id);
63 | comment.parent.dispose();
64 | }
65 | )
66 | );
67 |
68 | context.subscriptions.push(
69 | commands.registerCommand(
70 | `${EXTENSION_NAME}.editRepositoryComment`,
71 | async (comment: RepoCommitComment) =>
72 | updateComments(comment, CommentMode.Editing)
73 | )
74 | );
75 |
76 | commands.registerCommand(
77 | `${EXTENSION_NAME}.saveRepositoryComment`,
78 | async (comment: RepoCommitComment) => {
79 | const content =
80 | comment.body instanceof MarkdownString
81 | ? comment.body.value
82 | : comment.body;
83 |
84 | await editRepoComment(comment.repo, comment.id, content);
85 |
86 | updateComments(comment, CommentMode.Preview);
87 | }
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/images/dark/flash-code-secret.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/abstractions/node/images/pasteImageAsFile.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import { TextEditor } from "vscode";
3 | import * as config from "../../../config";
4 | import { DIRECTORY_SEPARATOR, FS_SCHEME } from "../../../constants";
5 | import { RepoFileSystemProvider } from "../../../repos/fileSystem";
6 | import { store } from "../../../store";
7 | import {
8 | encodeDirectoryName,
9 | fileNameToUri,
10 | getGistDetailsFromUri
11 | } from "../../../utils";
12 | import { clipboardToImageBuffer } from "./clipboardToImageBuffer";
13 | import { createImageMarkup } from "./utils/createImageMarkup";
14 | import { pasteImageMarkup } from "./utils/pasteImageMarkup";
15 |
16 | function getImageFileInfo(
17 | editor: TextEditor,
18 | fileName: string
19 | ): [vscode.Uri, string] {
20 | switch (editor.document.uri.scheme) {
21 | case FS_SCHEME: {
22 | const { gistId } = getGistDetailsFromUri(editor.document.uri);
23 |
24 | const src = `https://gist.github.com/${
25 | store.login
26 | }/${gistId}/raw/${encodeDirectoryName(fileName)}`;
27 |
28 | return [fileNameToUri(gistId, fileName), src];
29 | }
30 | default: {
31 | // TODO: Figure out a solution that will work for private repos
32 | const [repo] = RepoFileSystemProvider.getRepoInfo(editor.document.uri)!;
33 | const fileUri = RepoFileSystemProvider.getFileUri(repo.name, fileName);
34 | const src = `https://github.com/${repo.name}/raw/${repo.branch}/${fileName}`;
35 | return [fileUri, src];
36 | }
37 | }
38 | }
39 |
40 | function getImageFileName() {
41 | const uploadDirectory = config.get("images.directoryName");
42 | const prefix = uploadDirectory
43 | ? `${uploadDirectory}${DIRECTORY_SEPARATOR}`
44 | : "";
45 |
46 | const dateSting = new Date().toDateString().replace(/\s/g, "_");
47 | return `${prefix}${dateSting}_${Date.now()}.png`;
48 | }
49 |
50 | export async function pasteImageAsFile(
51 | editor: vscode.TextEditor,
52 | imageMarkupId: string | number
53 | ) {
54 | const fileName = getImageFileName();
55 | const imageBits = await clipboardToImageBuffer.getImageBits();
56 |
57 | const [uri, src] = getImageFileInfo(editor, fileName);
58 | try {
59 | await vscode.workspace.fs.writeFile(uri, imageBits);
60 | } catch (err) {
61 | // TODO: fs.writeFile gives an error which prevents pasting images from the clipboard
62 | // Error (FileSystemError): Unable to write file 'gist://Gist_ID/images/imageName.png'
63 | // (Error: [mobx] 'set()' can only be used on observable objects, arrays and maps)
64 | }
65 |
66 | const imageMarkup = await createImageMarkup(src, editor.document.languageId);
67 |
68 | await pasteImageMarkup(editor, imageMarkup, imageMarkupId);
69 | }
70 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const CopyPlugin = require("copy-webpack-plugin");
3 |
4 | const config = {
5 | mode: "production",
6 | entry: "./src/extension.ts",
7 | externals: {
8 | vscode: "commonjs vscode"
9 | },
10 | resolve: {
11 | extensions: [".ts", ".js", ".json"]
12 | },
13 | node: {
14 | __filename: false,
15 | __dirname: false
16 | },
17 | module: {
18 | rules: [
19 | {
20 | test: /\.ts$/,
21 | exclude: /node_modules/,
22 | use: [
23 | {
24 | loader: "ts-loader"
25 | }
26 | ]
27 | }
28 | ]
29 | }
30 | };
31 |
32 | const nodeConfig = {
33 | ...config,
34 | target: "node",
35 | output: {
36 | path: path.resolve(__dirname, "dist"),
37 | filename: "extension.js",
38 | libraryTarget: "commonjs2",
39 | devtoolModuleFilenameTemplate: "../[resource-path]"
40 | },
41 | resolve: {
42 | ...config.resolve,
43 | alias: {
44 | "@abstractions": path.join(__dirname, "./src/abstractions/node")
45 | }
46 | },
47 | plugins: [
48 | new CopyPlugin([
49 | {
50 | from: path.resolve(
51 | __dirname,
52 | "./src/abstractions/node/images/scripts/*"
53 | ),
54 | to: path.resolve(__dirname, "./dist/scripts/"),
55 | flatten: true
56 | }
57 | ])
58 | ]
59 | };
60 |
61 | const webConfig = {
62 | ...config,
63 | target: "webworker",
64 | output: {
65 | path: path.resolve(__dirname, "dist"),
66 | filename: "extension-web.js",
67 | libraryTarget: "commonjs2",
68 | devtoolModuleFilenameTemplate: "../[resource-path]"
69 | },
70 | externals: {
71 | ...config.externals,
72 | "simple-git": "commonjs simple-git"
73 | },
74 | resolve: {
75 | ...config.resolve,
76 | alias: {
77 | "@abstractions": path.join(__dirname, "./src/abstractions/browser"),
78 | "simple-git": path.resolve(
79 | __dirname,
80 | "src/abstractions/browser/simple-git"
81 | )
82 | },
83 | fallback: {
84 | child_process: false,
85 | crypto: false,
86 | fs: false, // TODO: Implement file uploading in the browser
87 | http: require.resolve("stream-http"),
88 | https: require.resolve("https-browserify"),
89 | os: require.resolve("os-browserify/browser"),
90 | path: require.resolve("path-browserify"),
91 | querystring: require.resolve("querystring-es3"),
92 | stream: false,
93 | url: require.resolve("url/"),
94 | util: require.resolve("util/"),
95 | zlib: false
96 | }
97 | }
98 | };
99 |
100 | module.exports = [nodeConfig, webConfig];
101 |
--------------------------------------------------------------------------------
/images/light/flash-code-secret.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/store/storage.ts:
--------------------------------------------------------------------------------
1 | import { reaction } from "mobx";
2 | import { commands, ExtensionContext, workspace } from "vscode";
3 | import { GroupType, SortOrder, store } from ".";
4 | import * as config from "../config";
5 | import { EXTENSION_NAME } from "../constants";
6 | import { output } from "../extension";
7 |
8 | const FOLLOW_KEY = "gistpad.followedUsers";
9 |
10 | // TODO: Replace these with user settings
11 | const SORT_ORDER_KEY = "gistpad:sortOrder";
12 | const GROUP_TYPE_KEY = "gistpad:groupType";
13 |
14 | const SHOW_DAILY_NOTES_KEY = "dailyNotes.show";
15 |
16 | export interface IStorage {
17 | followedUsers: string[];
18 | }
19 |
20 | function updateSortOrder(context: ExtensionContext, sortOrder: SortOrder) {
21 | context.globalState.update(SORT_ORDER_KEY, sortOrder);
22 | commands.executeCommand("setContext", SORT_ORDER_KEY, sortOrder);
23 | }
24 |
25 | function updateGroupType(context: ExtensionContext, groupType: GroupType) {
26 | context.globalState.update(GROUP_TYPE_KEY, groupType);
27 | commands.executeCommand("setContext", GROUP_TYPE_KEY, groupType);
28 | }
29 |
30 | export let followedUsersStorage: IStorage;
31 | export async function initializeStorage(context: ExtensionContext) {
32 | followedUsersStorage = {
33 | get followedUsers() {
34 | let followedUsers = context.globalState.get(FOLLOW_KEY, []).sort();
35 | output?.appendLine(
36 | `Getting followed users from global state = ${followedUsers}`,
37 | output.messageType.Info
38 | );
39 | return followedUsers;
40 | },
41 | set followedUsers(followedUsers: string[]) {
42 | output?.appendLine(
43 | `Setting followed users to ${followedUsers}`,
44 | output.messageType.Info
45 | );
46 | context.globalState.update(FOLLOW_KEY, followedUsers);
47 | }
48 | };
49 |
50 | const sortOrder = context.globalState.get(
51 | SORT_ORDER_KEY,
52 | SortOrder.updatedTime
53 | );
54 |
55 | store.sortOrder = sortOrder;
56 | commands.executeCommand("setContext", SORT_ORDER_KEY, sortOrder);
57 |
58 | reaction(
59 | () => [store.sortOrder],
60 | () => updateSortOrder(context, store.sortOrder)
61 | );
62 |
63 | const groupType = context.globalState.get(GROUP_TYPE_KEY, GroupType.none);
64 | store.groupType = groupType;
65 | commands.executeCommand("setContext", GROUP_TYPE_KEY, groupType);
66 |
67 | reaction(
68 | () => [store.groupType],
69 | () => updateGroupType(context, store.groupType)
70 | );
71 |
72 | store.dailyNotes.show = await config.get(SHOW_DAILY_NOTES_KEY);
73 |
74 | workspace.onDidChangeConfiguration(async (e) => {
75 | if (e.affectsConfiguration(`${EXTENSION_NAME}.${SHOW_DAILY_NOTES_KEY}`)) {
76 | store.dailyNotes.show = await config.get(SHOW_DAILY_NOTES_KEY);
77 | }
78 | });
79 | }
80 |
--------------------------------------------------------------------------------
/src/repos/wiki/utils.ts:
--------------------------------------------------------------------------------
1 | import { DocumentSelector } from "vscode";
2 | import { RepoFileSystemProvider, REPO_SCHEME } from "../fileSystem";
3 | import { Repository, Tree } from "../store";
4 | import { sanitizeName } from "../utils";
5 | import { config } from "./config";
6 |
7 | export const LINK_SELECTOR: DocumentSelector = [
8 | {
9 | scheme: REPO_SCHEME,
10 | language: "markdown"
11 | }
12 | ];
13 |
14 | export const LINK_PREFIX = "[[";
15 | export const LINK_SUFFIX = "]]";
16 | const LINK_PATTERN = /(?:#?\[\[)(?[^\]]+)(?:\]\])|#(?[^\s]+)/gi;
17 |
18 | const WIKI_REPO_PATTERNS = ["wiki", "notes", "obsidian", "journal"];
19 |
20 | const WIKI_WORKSPACE_FILES = [
21 | "gistpad.json",
22 | ".vscode/gistpad.json",
23 | ".vscode/foam.json"
24 | ];
25 |
26 | const DAILY_PATTERN = /\d{4}-\d{2}-\d{2}/;
27 | export function getPageFilePath(name: string) {
28 | let fileName = sanitizeName(name).toLocaleLowerCase();
29 | if (!fileName.endsWith(".md")) {
30 | fileName += ".md";
31 | }
32 |
33 | if (DAILY_PATTERN.test(fileName)) {
34 | const pathPrefix = config.dailyDirectName
35 | ? `${config.dailyDirectName}/`
36 | : "";
37 | return `${pathPrefix}${fileName}`;
38 | } else {
39 | return fileName;
40 | }
41 | }
42 |
43 | export interface WikiLink {
44 | title: string;
45 | start: number;
46 | end: number;
47 | contentStart: number;
48 | contentEnd: number;
49 | }
50 |
51 | export function* findLinks(contents: string): Generator {
52 | let match;
53 | while ((match = LINK_PATTERN.exec(contents))) {
54 | const title = match.groups!.page || match.groups!.tag;
55 | const start = match.index;
56 | const end = start + match[0].length;
57 | const contentStart = start + match[0].indexOf(title);
58 | const contentEnd = contentStart + title.length;
59 |
60 | yield {
61 | title,
62 | start,
63 | end,
64 | contentStart,
65 | contentEnd
66 | };
67 | }
68 | }
69 |
70 | export function getTreeItemFromLink(repo: Repository, link: string) {
71 | return repo.tree!.tree.find(
72 | (item) =>
73 | item.displayName?.toLocaleLowerCase() === link.toLocaleLowerCase() ||
74 | item.path === link ||
75 | item.path.replace(".md", "") === link
76 | );
77 | }
78 |
79 | export function getUriFromLink(repo: Repository, link: string) {
80 | const treeItem = getTreeItemFromLink(repo, link);
81 | return RepoFileSystemProvider.getFileUri(repo.name, treeItem?.path);
82 | }
83 |
84 | export function isWiki(repo: Repository, tree?: Tree) {
85 | const repoTree = tree || repo.tree;
86 | return (
87 | WIKI_REPO_PATTERNS.some((pattern) =>
88 | repo.name.toLocaleLowerCase().includes(pattern)
89 | ) ||
90 | !!repoTree?.tree.some((item) => WIKI_WORKSPACE_FILES.includes(item.path))
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/images/dark/code-swing-tutorial.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/images/light/code-swing-tutorial.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/repos/tree/nodes.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ThemeIcon,
3 | TreeItem,
4 | TreeItemCollapsibleState,
5 | Uri
6 | } from "vscode";
7 | import { RepoFileSystemProvider } from "../fileSystem";
8 | import { Repository, RepositoryFile, store, TreeItemBackLink } from "../store";
9 |
10 | export class RepositoryNode extends TreeItem {
11 | constructor(public repo: Repository) {
12 | super(repo.name, TreeItemCollapsibleState.Expanded);
13 |
14 | const iconName = repo.isWiki ? "book" : "repo";
15 | this.iconPath = new ThemeIcon(iconName);
16 |
17 | this.contextValue =
18 | "gistpad." + (repo.isSwing ? "swing" : repo.isWiki ? "wiki" : "repo");
19 |
20 | if (repo.isWiki && store.wiki?.name === repo.name) {
21 | this.description = "Primary";
22 | }
23 |
24 | if (repo.branch !== repo.defaultBranch) {
25 | this.contextValue += ".branch";
26 | this.description = repo.branch;
27 | }
28 |
29 | if (repo.hasTours) {
30 | this.contextValue += ".hasTours";
31 | }
32 |
33 | this.tooltip = `Repo: ${repo.name}
34 | Branch: ${repo.branch}`;
35 | }
36 | }
37 |
38 | export class RepositoryFileNode extends TreeItem {
39 | constructor(public repo: Repository, public file: RepositoryFile) {
40 | super(
41 | file.name,
42 | file.isDirectory || file.backLinks
43 | ? TreeItemCollapsibleState.Collapsed
44 | : TreeItemCollapsibleState.None
45 | );
46 |
47 | this.iconPath = file.isDirectory ? ThemeIcon.Folder : ThemeIcon.File;
48 | this.resourceUri = file.uri;
49 |
50 | if (!file.isDirectory) {
51 | this.command = {
52 | command: "vscode.open",
53 | title: "Open file",
54 | arguments: [file.uri]
55 | };
56 | }
57 |
58 | if (repo.isWiki && file.backLinks) {
59 | this.description = file.backLinks.length.toString();
60 | } else if (file.isDirectory) {
61 | this.description = file.files!.length.toString();
62 | }
63 |
64 | const repoType = repo.isWiki ? "wiki" : "repo";
65 | this.contextValue = file.isDirectory
66 | ? `gistpad.${repoType}Directory`
67 | : "gistpad.repoFile";
68 | }
69 | }
70 |
71 | function getbackLinkDisplayName(uri: Uri) {
72 | const [, file] = RepoFileSystemProvider.getRepoInfo(uri)!;
73 | return file?.displayName || file?.path || "";
74 | }
75 |
76 | export class RepositoryFileBackLinkNode extends TreeItem {
77 | constructor(repo: string, public backLink: TreeItemBackLink) {
78 | super(
79 | getbackLinkDisplayName(backLink.location.uri),
80 | TreeItemCollapsibleState.None
81 | );
82 |
83 | this.description = backLink.linePreview;
84 | this.tooltip = backLink.linePreview;
85 |
86 | this.command = {
87 | command: "vscode.open",
88 | arguments: [
89 | backLink.location.uri,
90 | { selection: backLink.location.range }
91 | ],
92 | title: "Open File"
93 | };
94 |
95 | this.contextValue = "gistpad.repoFile.backLink";
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/showcase/tree.ts:
--------------------------------------------------------------------------------
1 | import { reaction } from "mobx";
2 | import {
3 | Disposable,
4 | Event,
5 | EventEmitter,
6 | ExtensionContext,
7 | ProviderResult,
8 | TreeDataProvider,
9 | TreeItem,
10 | TreeItemCollapsibleState,
11 | window
12 | } from "vscode";
13 | import { EXTENSION_NAME } from "../constants";
14 | import { getGistFiles } from "../tree";
15 | import {
16 | FollowedUserGistNode,
17 | GistDirectoryNode,
18 | GistNode,
19 | LoadingNode,
20 | TreeNode
21 | } from "../tree/nodes";
22 | import { isOwnedGist } from "../utils";
23 | import { GistShowcaseCategory, store } from "./store";
24 |
25 | export class GistShowcaseCategoryNode extends TreeNode {
26 | constructor(public category: GistShowcaseCategory) {
27 | super(category.title, TreeItemCollapsibleState.Expanded);
28 | this.contextValue = "showcase.category";
29 | }
30 | }
31 |
32 | class ShowcaseTreeProvider implements TreeDataProvider, Disposable {
33 | private _disposables: Disposable[] = [];
34 |
35 | private _onDidChangeTreeData = new EventEmitter();
36 | public readonly onDidChangeTreeData: Event = this._onDidChangeTreeData
37 | .event;
38 |
39 | constructor(private extensionContext: ExtensionContext) {
40 | reaction(
41 | () => [
42 | store.showcase.isLoading,
43 | store.showcase?.categories.map((category) => [category.isLoading])
44 | ],
45 | () => {
46 | this._onDidChangeTreeData.fire();
47 | }
48 | );
49 | }
50 |
51 | getTreeItem(node: TreeNode): TreeItem {
52 | return node;
53 | }
54 |
55 | getChildren(element?: TreeNode): ProviderResult {
56 | if (!element) {
57 | if (store.showcase.isLoading) {
58 | return [new TreeNode("Loading showcase...")];
59 | }
60 |
61 | return store.showcase?.categories.map(
62 | (category) => new GistShowcaseCategoryNode(category)
63 | );
64 | } else if (element instanceof GistShowcaseCategoryNode) {
65 | if (element.category.isLoading) {
66 | return [new LoadingNode()];
67 | }
68 |
69 | return element.category.gists.map((gist) => {
70 | const owned = isOwnedGist(gist.id);
71 |
72 | return owned
73 | ? new GistNode(gist, this.extensionContext)
74 | : new FollowedUserGistNode(gist, this.extensionContext);
75 | });
76 | } else if (element instanceof GistNode) {
77 | return getGistFiles(element.gist);
78 | } else if (element instanceof GistDirectoryNode) {
79 | return getGistFiles(element.gist, element.directory);
80 | }
81 | }
82 |
83 | dispose() {
84 | this._disposables.forEach((disposable) => disposable.dispose());
85 | }
86 | }
87 |
88 | export function registerTreeProvider(extensionContext: ExtensionContext) {
89 | window.createTreeView(`${EXTENSION_NAME}.showcase`, {
90 | showCollapseAll: true,
91 | treeDataProvider: new ShowcaseTreeProvider(extensionContext),
92 | canSelectMany: true
93 | });
94 | }
95 |
--------------------------------------------------------------------------------
/src/repos/wiki/completionProvider.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CompletionItem,
3 | CompletionItemKind,
4 | CompletionItemProvider,
5 | languages,
6 | Position,
7 | Range,
8 | TextDocument
9 | } from "vscode";
10 | import { EXTENSION_NAME } from "../../constants";
11 | import { RepoFileSystemProvider } from "../fileSystem";
12 | import {
13 | getTreeItemFromLink,
14 | LINK_PREFIX,
15 | LINK_SELECTOR,
16 | LINK_SUFFIX
17 | } from "./utils";
18 |
19 | class WikiLinkCompletionProvider implements CompletionItemProvider {
20 | provideCompletionItems(
21 | document: TextDocument,
22 | position: Position
23 | ): CompletionItem[] | undefined {
24 | const [repo, file] = RepoFileSystemProvider.getRepoInfo(document.uri)!;
25 | if (!repo.isWiki) {
26 | return;
27 | }
28 |
29 | const lineText = document
30 | .lineAt(position)
31 | .text.substr(0, position.character);
32 |
33 | const linkOpening = lineText.lastIndexOf(LINK_PREFIX);
34 | if (linkOpening === -1) {
35 | return;
36 | }
37 |
38 | const link = lineText.substr(linkOpening + LINK_PREFIX.length);
39 | if (link === undefined || link.includes(LINK_SUFFIX)) {
40 | return;
41 | }
42 |
43 | const documents = repo.documents.filter((doc) => doc.path !== file?.path);
44 | const documentItems = documents.map((doc) => {
45 | const item = new CompletionItem(
46 | doc.displayName || doc.path,
47 | CompletionItemKind.File
48 | );
49 |
50 | // Automatically save the document upon selection
51 | // in order to update the backlinks in the tree.
52 | item.command = {
53 | command: "workbench.action.files.save",
54 | title: "Reference document"
55 | };
56 |
57 | return item;
58 | });
59 |
60 | if (!getTreeItemFromLink(repo, link)) {
61 | const newDocumentItem = new CompletionItem(link, CompletionItemKind.File);
62 | newDocumentItem.detail = `Create new page page "${link}"`;
63 |
64 | // Since we're dynamically updating the range as the user types,
65 | // we need to ensure the range spans the enter document name.
66 | newDocumentItem.range = new Range(
67 | position.translate({ characterDelta: -link.length }),
68 | position
69 | );
70 |
71 | // As soon as the user accepts this item,
72 | // automatically create the new document.
73 | newDocumentItem.command = {
74 | command: `${EXTENSION_NAME}._createWikiPage`,
75 | title: "Create new page",
76 | arguments: [repo, link]
77 | };
78 |
79 | documentItems.unshift(newDocumentItem);
80 | }
81 |
82 | return documentItems;
83 | }
84 | }
85 |
86 | let triggerCharacters = [...Array(94).keys()].map((i) =>
87 | String.fromCharCode(i + 32)
88 | );
89 |
90 | export async function registerLinkCompletionProvider() {
91 | languages.registerCompletionItemProvider(
92 | LINK_SELECTOR,
93 | new WikiLinkCompletionProvider(),
94 | ...triggerCharacters
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/tour.ts:
--------------------------------------------------------------------------------
1 | import { Event, extensions, Uri } from "vscode";
2 | import { getFileContents } from "./fileSystem/api";
3 | import { GistFile } from "./store";
4 |
5 | export const TOUR_FILE = "main.tour";
6 |
7 | interface CodeTourApi {
8 | startTour(
9 | tour: any,
10 | stepNumber: number,
11 | workspaceRoot: Uri,
12 | startInEditMode: boolean,
13 | canEdit: boolean
14 | ): void;
15 |
16 | endCurrentTour(): void;
17 | exportTour(tour: any): string;
18 | recordTour(workspaceRoot: Uri): void;
19 |
20 | promptForTour(workspaceRoot: Uri, tours: any[]): Promise;
21 | selectTour(tours: any[], workspaceRoot: Uri): Promise;
22 |
23 | onDidEndTour: Event;
24 | }
25 |
26 | let codeTourApi: CodeTourApi;
27 | export async function ensureApi() {
28 | if (!codeTourApi) {
29 | const codeTour = extensions.getExtension("vsls-contrib.codetour");
30 | if (!codeTour) {
31 | return;
32 | }
33 | if (!codeTour.isActive) {
34 | await codeTour.activate();
35 | }
36 |
37 | codeTourApi = codeTour.exports;
38 | }
39 | }
40 |
41 | export async function isCodeTourInstalled() {
42 | await ensureApi();
43 | return !!codeTourApi;
44 | }
45 |
46 | export async function startTour(
47 | tour: any,
48 | workspaceRoot: Uri,
49 | startInEditMode: boolean = false,
50 | canEdit: boolean = true
51 | ) {
52 | await ensureApi();
53 |
54 | tour.id = `${workspaceRoot.toString()}/${TOUR_FILE}`;
55 | codeTourApi.startTour(tour, 0, workspaceRoot, startInEditMode, canEdit);
56 | }
57 |
58 | export async function startTourFromFile(
59 | tourFile: GistFile,
60 | workspaceRoot: Uri,
61 | startInEditMode: boolean = false,
62 | canEdit: boolean = true
63 | ) {
64 | await ensureApi();
65 |
66 | const tourContent = await getFileContents(tourFile);
67 | if (!tourContent) {
68 | return;
69 | }
70 |
71 | try {
72 | const tour = JSON.parse(tourContent);
73 | startTour(tour, workspaceRoot, startInEditMode, canEdit);
74 | } catch (e) {}
75 | }
76 |
77 | export async function endCurrentTour() {
78 | await ensureApi();
79 | codeTourApi.endCurrentTour();
80 | }
81 |
82 | export async function exportTour(tour: any) {
83 | await ensureApi();
84 | return codeTourApi.exportTour(tour);
85 | }
86 |
87 | export async function recordTour(workspaceRoot: Uri) {
88 | await ensureApi();
89 | return codeTourApi.recordTour(workspaceRoot);
90 | }
91 |
92 | export async function promptForTour(workspaceRoot: Uri, tours: any[]) {
93 | await ensureApi();
94 | return codeTourApi.promptForTour(workspaceRoot, tours);
95 | }
96 |
97 | export async function onDidEndTour(listener: (tour: any) => void) {
98 | await ensureApi();
99 | return codeTourApi.onDidEndTour(listener);
100 | }
101 |
102 | export async function selectTour(tours: any[], workspaceRoot: Uri) {
103 | await ensureApi();
104 | return codeTourApi.selectTour(tours, workspaceRoot);
105 | }
106 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ "master" ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ "master" ]
20 | schedule:
21 | - cron: '31 21 * * 6'
22 | workflow_dispatch:
23 |
24 | jobs:
25 | analyze:
26 | name: Analyze
27 | runs-on: ubuntu-latest
28 | permissions:
29 | actions: read
30 | contents: read
31 | security-events: write
32 |
33 | strategy:
34 | fail-fast: false
35 | matrix:
36 | language: [ 'javascript' ]
37 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
38 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
39 |
40 | steps:
41 | - name: Checkout repository
42 | uses: actions/checkout@v3
43 |
44 | # Initializes the CodeQL tools for scanning.
45 | - name: Initialize CodeQL
46 | uses: github/codeql-action/init@v2
47 | with:
48 | languages: ${{ matrix.language }}
49 | # If you wish to specify custom queries, you can do so here or in a config file.
50 | # By default, queries listed here will override any specified in a config file.
51 | # Prefix the list here with "+" to use these queries and those in the config file.
52 |
53 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
54 | queries: security-extended,security-and-quality
55 |
56 |
57 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
58 | # If this step fails, then you should remove it and run the build manually (see below)
59 | - name: Autobuild
60 | uses: github/codeql-action/autobuild@v2
61 |
62 | # ℹ️ Command-line programs to run using the OS shell.
63 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
64 |
65 | # If the Autobuild fails above, remove it and uncomment the following three lines.
66 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
67 |
68 | # - run: |
69 | # echo "Run, Build Application using script"
70 | # ./location_of_script_within_repo/buildscript.sh
71 |
72 | - name: Perform CodeQL Analysis
73 | uses: github/codeql-action/analyze@v2
74 | with:
75 | category: "/language:${{matrix.language}}"
76 |
--------------------------------------------------------------------------------
/src/commands/comments.ts:
--------------------------------------------------------------------------------
1 | import {
2 | commands,
3 | CommentMode,
4 | CommentReply,
5 | ExtensionContext,
6 | MarkdownString
7 | } from "vscode";
8 | import { GistCodeComment } from "../comments";
9 | import { EXTENSION_NAME } from "../constants";
10 | import {
11 | createGistComment,
12 | deleteGistComment,
13 | editGistComment
14 | } from "../store/actions";
15 | import { getCurrentUser } from "../store/auth";
16 | import { getGistDetailsFromUri } from "../utils";
17 |
18 | async function addComment(reply: CommentReply) {
19 | let thread = reply.thread;
20 |
21 | const { gistId } = getGistDetailsFromUri(thread.uri);
22 | const comment = await createGistComment(gistId, reply.text);
23 |
24 | let newComment = new GistCodeComment(
25 | comment,
26 | gistId,
27 | thread,
28 | getCurrentUser()
29 | );
30 |
31 | thread.comments = [...thread.comments, newComment];
32 | }
33 |
34 | export function registerCommentCommands(context: ExtensionContext) {
35 | context.subscriptions.push(
36 | commands.registerCommand(`${EXTENSION_NAME}.addGistComment`, addComment)
37 | );
38 |
39 | context.subscriptions.push(
40 | commands.registerCommand(`${EXTENSION_NAME}.replyGistComment`, addComment)
41 | );
42 |
43 | context.subscriptions.push(
44 | commands.registerCommand(
45 | `${EXTENSION_NAME}.editGistComment`,
46 | async (comment: GistCodeComment) => {
47 | if (!comment.parent) {
48 | return;
49 | }
50 |
51 | comment.parent.comments = comment.parent.comments.map((cmt) => {
52 | if ((cmt as GistCodeComment).id === comment.id) {
53 | cmt.mode = CommentMode.Editing;
54 | }
55 |
56 | return cmt;
57 | });
58 | }
59 | )
60 | );
61 |
62 | commands.registerCommand(
63 | `${EXTENSION_NAME}.saveGistComment`,
64 | async (comment: GistCodeComment) => {
65 | if (!comment.parent) {
66 | return;
67 | }
68 |
69 | const content =
70 | comment.body instanceof MarkdownString
71 | ? comment.body.value
72 | : comment.body;
73 |
74 | await editGistComment(comment.gistId, comment.id, content);
75 |
76 | comment.parent.comments = comment.parent.comments.map((cmt) => {
77 | if ((cmt as GistCodeComment).id === comment.id) {
78 | cmt.mode = CommentMode.Preview;
79 | }
80 |
81 | return cmt;
82 | });
83 | }
84 | );
85 |
86 | context.subscriptions.push(
87 | commands.registerCommand(
88 | `${EXTENSION_NAME}.deleteGistComment`,
89 | async (comment: GistCodeComment) => {
90 | let thread = comment.parent;
91 | if (!thread) {
92 | return;
93 | }
94 |
95 | await deleteGistComment(comment.gistId, comment.id);
96 | thread.comments = thread.comments.filter(
97 | (cmt) => (cmt as GistCodeComment).id !== comment.id
98 | );
99 |
100 | if (thread.comments.length === 0) {
101 | thread.dispose();
102 | }
103 | }
104 | )
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/images/dark/notebook-secret.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/images/light/notebook-secret.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/images/dark/sort-alphabetical.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/images/light/sort-alphabetical.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/images/daily.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/repos/wiki/comments.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Comment,
3 | CommentAuthorInformation,
4 | CommentController,
5 | CommentMode,
6 | comments,
7 | CommentThreadCollapsibleState,
8 | MarkdownString,
9 | Range,
10 | Uri,
11 | window,
12 | workspace
13 | } from "vscode";
14 | import { RepoFileSystemProvider } from "../fileSystem";
15 | import { TreeItemBackLink } from "../store";
16 |
17 | export class WikiBacklinksComments implements Comment {
18 | public body: string | MarkdownString;
19 | public mode: CommentMode = CommentMode.Preview;
20 | public author: CommentAuthorInformation;
21 |
22 | constructor(backlinks: TreeItemBackLink[]) {
23 | const content = backlinks
24 | .map((link) => {
25 | const [, file] = RepoFileSystemProvider.getRepoInfo(link.location.uri)!;
26 | const title = file!.displayName || file!.path;
27 | const args = [
28 | link.location.uri,
29 | {
30 | selection: {
31 | start: {
32 | line: link.location.range.start.line,
33 | character: link.location.range.start.character
34 | },
35 | end: {
36 | line: link.location.range.end.line,
37 | character: link.location.range.end.character
38 | }
39 | }
40 | }
41 | ];
42 | const command = `command:vscode.open?${encodeURIComponent(
43 | JSON.stringify(args)
44 | )}`;
45 | return `### [${title}](${command} 'Open the "${title}" page')
46 |
47 | \`\`\`markdown
48 | ${link.linePreview}
49 | \`\`\``;
50 | })
51 | .join("\r\n");
52 |
53 | const markdown = new MarkdownString(content);
54 | markdown.isTrusted = true;
55 |
56 | this.body = markdown;
57 |
58 | this.author = {
59 | name: "GistPad (Backlinks)",
60 | iconPath: Uri.parse(
61 | "https://cdn.jsdelivr.net/gh/vsls-contrib/gistpad/images/icon.png"
62 | )
63 | };
64 | }
65 | }
66 |
67 | let controller: CommentController | undefined;
68 | export function registerCommentController() {
69 | window.onDidChangeActiveTextEditor((e) => {
70 | if (controller) {
71 | controller.dispose();
72 | controller = undefined;
73 | }
74 |
75 | if (!e || !RepoFileSystemProvider.isRepoDocument(e.document)) {
76 | return;
77 | }
78 |
79 | const info = RepoFileSystemProvider.getRepoInfo(e.document.uri)!;
80 | if (!info || !info[0].isWiki || !info[1]?.backLinks) {
81 | return;
82 | }
83 |
84 | controller = comments.createCommentController("gistpad.wiki", "Backlinks");
85 | const comment = new WikiBacklinksComments(info[1].backLinks);
86 | const thread = controller.createCommentThread(
87 | e.document.uri,
88 | new Range(e.document.lineCount, 0, e.document.lineCount, 0),
89 | [comment]
90 | );
91 | // @ts-ignore
92 | thread.canReply = false;
93 | thread.collapsibleState = CommentThreadCollapsibleState.Expanded;
94 |
95 | workspace.onDidChangeTextDocument((change) => {
96 | if (change.document.uri.toString() === e.document.uri.toString()) {
97 | thread.range = new Range(
98 | e.document.lineCount,
99 | 0,
100 | e.document.lineCount,
101 | 0
102 | );
103 | }
104 | });
105 | });
106 | }
107 |
--------------------------------------------------------------------------------
/src/comments/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Comment,
3 | CommentAuthorInformation,
4 | CommentMode,
5 | comments,
6 | CommentThread,
7 | CommentThreadCollapsibleState,
8 | MarkdownString,
9 | Range,
10 | TextDocument,
11 | Uri,
12 | workspace
13 | } from "vscode";
14 | import * as config from "../config";
15 | import { EXTENSION_NAME } from "../constants";
16 | import { GistComment } from "../store";
17 | import { getGistComments } from "../store/actions";
18 | import { getCurrentUser } from "../store/auth";
19 | import { getGistDetailsFromUri, isGistDocument } from "../utils";
20 |
21 | export class GistCodeComment implements Comment {
22 | public contextValue: string;
23 | public body: string | MarkdownString;
24 | public id: string;
25 | public label: string;
26 | public mode: CommentMode = CommentMode.Preview;
27 | public author: CommentAuthorInformation;
28 |
29 | constructor(
30 | comment: GistComment,
31 | public gistId: string,
32 | public parent: CommentThread,
33 | public currentUser: string
34 | ) {
35 | this.id = comment.id;
36 | this.body = new MarkdownString(comment.body);
37 | this.author = {
38 | name: comment.user.login,
39 | iconPath: Uri.parse(comment.user.avatar_url)
40 | };
41 | this.label = comment.author_association === "OWNER" ? "Owner" : "";
42 | this.contextValue = comment.user.login === currentUser ? "canEdit" : "";
43 | }
44 | }
45 |
46 | function commentRange(document: TextDocument) {
47 | return new Range(document.lineCount, 0, document.lineCount, 0);
48 | }
49 |
50 | const documentComments = new Map();
51 | export async function registerCommentController() {
52 | const controller = comments.createCommentController(EXTENSION_NAME, "Gist");
53 | controller.commentingRangeProvider = {
54 | provideCommentingRanges: (document) => {
55 | if (isGistDocument(document)) {
56 | return [commentRange(document)];
57 | }
58 | }
59 | };
60 |
61 | workspace.onDidOpenTextDocument(async (document) => {
62 | if (
63 | isGistDocument(document) &&
64 | !documentComments.has(document.uri.toString())
65 | ) {
66 | const { gistId } = getGistDetailsFromUri(document.uri);
67 | const comments = await getGistComments(gistId);
68 |
69 | if (comments.length > 0) {
70 | const thread = controller.createCommentThread(
71 | document.uri,
72 | commentRange(document),
73 | []
74 | );
75 |
76 | const currentUser = getCurrentUser();
77 |
78 | thread.comments = comments.map(
79 | (comment) => new GistCodeComment(comment, gistId, thread, currentUser)
80 | );
81 |
82 | const showCommentThread = config.get("comments.showThread");
83 | if (
84 | showCommentThread === "always" ||
85 | (showCommentThread === "whenNotEmpty" && thread.comments.length > 0)
86 | ) {
87 | thread.collapsibleState = CommentThreadCollapsibleState.Expanded;
88 | } else {
89 | thread.collapsibleState = CommentThreadCollapsibleState.Collapsed;
90 | }
91 |
92 | workspace.onDidChangeTextDocument((e) => {
93 | if (e.document === document) {
94 | thread.range = commentRange(document);
95 | }
96 | });
97 |
98 | documentComments.set(document.uri.toString(), thread);
99 | }
100 | }
101 | });
102 | }
103 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { observable } from "mobx";
2 |
3 | interface User {
4 | id: number;
5 | login: string;
6 | avatar_url: string;
7 | html_url: string;
8 | }
9 |
10 | export interface GistFile {
11 | filename?: string;
12 | content?: string;
13 | type?: string;
14 | size?: number;
15 | raw_url?: string;
16 | truncated?: boolean;
17 | }
18 |
19 | export interface GistRevisionStatus {
20 | total: number;
21 | additions: number;
22 | deletions: number;
23 | }
24 |
25 | export interface GistRevision {
26 | user: User;
27 | version: string;
28 | committed_at: string;
29 | change_status: GistRevisionStatus;
30 | }
31 |
32 | export const GistTypes: GistType[] = [
33 | "code-snippet",
34 | "note",
35 | "code-swing",
36 | "code-swing-template",
37 | "notebook",
38 | "code-swing-tutorial",
39 | "code-tour",
40 | "diagram",
41 | "flash-code"
42 | ];
43 |
44 | export type GistType =
45 | | "code-snippet"
46 | | "note"
47 | | "code-swing"
48 | | "code-swing-template"
49 | | "notebook"
50 | | "code-swing-tutorial"
51 | | "code-tour"
52 | | "diagram"
53 | | "flash-code";
54 |
55 | export type GistGroupType = GistType | "tag";
56 |
57 | export interface Gist {
58 | id: string;
59 | files: { [fileName: string]: GistFile };
60 | html_url: string;
61 | truncated: boolean;
62 | url: string;
63 | description: string;
64 | owner: User;
65 | public: boolean;
66 | created_at: string;
67 | updated_at: string;
68 | history: GistRevision[];
69 | git_pull_url: string;
70 | type?: GistType;
71 | tags?: string[];
72 | }
73 |
74 | export interface GistComment {
75 | id: string;
76 | body: string;
77 | user: User;
78 | created_at: string;
79 | updated_at: string;
80 | author_association: "NONE" | "OWNER";
81 | }
82 |
83 | export interface FollowedUser {
84 | username: string;
85 | gists: Gist[];
86 | avatarUrl?: string;
87 | isLoading: boolean;
88 | }
89 |
90 | export enum SortOrder {
91 | alphabetical = "alphabetical",
92 | updatedTime = "updatedTime"
93 | }
94 |
95 | export enum GroupType {
96 | none = "none",
97 | tag = "tag",
98 | tagAndType = "tagAndType"
99 | }
100 |
101 | export interface DailyNotes {
102 | gist: Gist | null;
103 | show: boolean;
104 | }
105 |
106 | export interface Store {
107 | dailyNotes: DailyNotes;
108 | followedUsers: FollowedUser[];
109 | gists: Gist[];
110 | archivedGists: Gist[];
111 | isLoading: boolean;
112 | isSignedIn: boolean;
113 | login: string;
114 | token?: string;
115 | sortOrder: SortOrder;
116 | groupType: GroupType;
117 | starredGists: Gist[];
118 | canCreateRepos: boolean;
119 | canDeleteRepos: boolean;
120 | unsyncedFiles: Set;
121 | }
122 |
123 | export function findGistInStore(gistId: string) {
124 | if (store.dailyNotes.gist?.id === gistId) {
125 | return store.dailyNotes.gist;
126 | }
127 |
128 | return store.gists
129 | .concat(store.archivedGists)
130 | .concat(store.starredGists)
131 | .find((gist) => gist.id === gistId);
132 | }
133 |
134 | export const store: Store = observable({
135 | dailyNotes: {
136 | gist: null,
137 | show: false
138 | },
139 | followedUsers: [],
140 | gists: [],
141 | archivedGists: [],
142 | isLoading: false,
143 | isSignedIn: false,
144 | login: "",
145 | sortOrder: SortOrder.updatedTime,
146 | groupType: GroupType.none,
147 | starredGists: [],
148 | canCreateRepos: false,
149 | canDeleteRepos: false,
150 | unsyncedFiles: new Set()
151 | });
152 |
--------------------------------------------------------------------------------
/src/repos/tree/index.ts:
--------------------------------------------------------------------------------
1 | import { reaction } from "mobx";
2 | import {
3 | Event,
4 | EventEmitter,
5 | ProviderResult,
6 | TreeDataProvider,
7 | TreeItem,
8 | TreeView,
9 | window
10 | } from "vscode";
11 | import { EXTENSION_NAME } from "../../constants";
12 | import { store as authStore } from "../../store";
13 | import { Repository, RepositoryFile, store } from "../store";
14 | import {
15 | RepositoryFileBackLinkNode,
16 | RepositoryFileNode,
17 | RepositoryNode
18 | } from "./nodes";
19 |
20 | class RepositoryTreeProvider implements TreeDataProvider {
21 | private _onDidChangeTreeData = new EventEmitter();
22 | public readonly onDidChangeTreeData: Event = this._onDidChangeTreeData
23 | .event;
24 |
25 | constructor() {
26 | reaction(
27 | () => [
28 | authStore.isSignedIn,
29 | store.repos.map((repo) => [
30 | repo.isLoading,
31 | repo.hasTours,
32 | repo.tree
33 | ? repo.tree.tree.map((item) => [
34 | item.path,
35 | item.displayName,
36 | item.backLinks
37 | ? item.backLinks.map((link) => link.linePreview)
38 | : null
39 | ])
40 | : null
41 | ])
42 | ],
43 | () => {
44 | this._onDidChangeTreeData.fire();
45 | }
46 | );
47 | }
48 |
49 | getBackLinkNodes(file: RepositoryFile, repo: Repository) {
50 | return file.backLinks?.map(
51 | (backLink) => new RepositoryFileBackLinkNode(repo.name, backLink)
52 | );
53 | }
54 |
55 | getFileNodes(parent: Repository | RepositoryFile, repo: Repository) {
56 | return parent.files?.map((file) => new RepositoryFileNode(repo, file));
57 | }
58 |
59 | getTreeItem = (node: TreeItem) => node;
60 |
61 | getChildren(element?: TreeItem): ProviderResult {
62 | if (!element && authStore.isSignedIn && store.repos.length > 0) {
63 | return store.repos
64 | .slice().sort((a, b) => a.name.localeCompare(b.name))
65 | .map((repo) => new RepositoryNode(repo));
66 | } else if (element instanceof RepositoryNode) {
67 | if (element.repo.isLoading) {
68 | return [new TreeItem("Loading repository...")];
69 | }
70 |
71 | const fileNodes = this.getFileNodes(element.repo, element.repo);
72 | if (fileNodes) {
73 | return fileNodes;
74 | } else {
75 | const addItemSuffix = element.repo.isWiki ? "page" : "file";
76 | const addFileItem = new TreeItem(`Add new ${addItemSuffix}`);
77 |
78 | const addItemCommand = element.repo.isWiki
79 | ? "addWikiPage"
80 | : "addRepositoryFile";
81 |
82 | addFileItem.command = {
83 | command: `${EXTENSION_NAME}.${addItemCommand}`,
84 | title: `Add new ${addItemSuffix}`,
85 | arguments: [element]
86 | };
87 |
88 | return [addFileItem];
89 | }
90 | } else if (element instanceof RepositoryFileNode) {
91 | if (element.file.isDirectory) {
92 | return this.getFileNodes(element.file, element.repo);
93 | } else if (element.file.backLinks) {
94 | return this.getBackLinkNodes(element.file, element.repo);
95 | }
96 | }
97 | }
98 |
99 | getParent(element: TreeItem): TreeItem | undefined {
100 | return undefined;
101 | }
102 | }
103 |
104 | let treeView: TreeView;
105 |
106 | export function focusRepo(repo: Repository) {
107 | treeView.reveal(new RepositoryNode(repo));
108 | }
109 |
110 | export function registerTreeProvider() {
111 | treeView = window.createTreeView(`${EXTENSION_NAME}.repos`, {
112 | showCollapseAll: true,
113 | treeDataProvider: new RepositoryTreeProvider(),
114 | canSelectMany: true
115 | });
116 | }
117 |
--------------------------------------------------------------------------------
/src/fileSystem/git.ts:
--------------------------------------------------------------------------------
1 | import * as fs from "fs";
2 | import * as os from "os";
3 | import * as path from "path";
4 | import { simpleGit as git, SimpleGit } from "simple-git";
5 | import { store } from "../store";
6 | import { refreshGist } from "../store/actions";
7 | import { getToken } from "../store/auth";
8 |
9 | async function ensureRepo(gistId: string): Promise<[string, SimpleGit]> {
10 | const repoPath = path.join(os.tmpdir(), gistId);
11 | const repoExists = fs.existsSync(repoPath);
12 |
13 | let repo;
14 | if (repoExists) {
15 | repo = git(repoPath);
16 | const isRepo = await repo.checkIsRepo();
17 | if (isRepo) {
18 | await repo.pull("origin", "main", { "--force": null });
19 | } else {
20 | await repo.init();
21 | }
22 | } else {
23 | const token = await getToken();
24 | const remote = `https://${store.login}:${token}@gist.github.com/${gistId}.git`;
25 | await git(os.tmpdir()).silent(true).clone(remote);
26 |
27 | // Reset the git instance to point
28 | // at the newly cloned folder.
29 | repo = git(repoPath);
30 | }
31 |
32 | return [repoPath, repo];
33 | }
34 |
35 | export async function addFile(
36 | gistId: string,
37 | fileName: string,
38 | content: Uint8Array
39 | ) {
40 | const [repoPath, repo] = await ensureRepo(gistId);
41 | const filePath = path.join(repoPath, fileName);
42 |
43 | fs.writeFileSync(filePath, content);
44 |
45 | await repo.add(filePath);
46 | await repo.commit(`Adding ${fileName}`);
47 | await repo.push("origin", "main");
48 |
49 | return refreshGist(gistId);
50 | }
51 |
52 | export async function renameFile(
53 | gistId: string,
54 | fileName: string,
55 | newFileName: string
56 | ) {
57 | const [repoPath, repo] = await ensureRepo(gistId);
58 |
59 | const filePath = path.join(repoPath, fileName);
60 | const newFilePath = path.join(repoPath, newFileName);
61 | fs.renameSync(filePath, newFilePath);
62 |
63 | await repo.add([filePath, newFilePath]);
64 | await repo.commit(`Renaming ${fileName} to ${newFileName}`);
65 | await repo.push("origin", "main");
66 |
67 | return refreshGist(gistId);
68 | }
69 |
70 | export async function exportToRepo(gistId: string, repoName: string) {
71 | const [, repo] = await ensureRepo(gistId);
72 | const token = await getToken();
73 |
74 | return pushRemote(
75 | repo,
76 | "export",
77 | `https://${store.login}:${token}@github.com/${store.login}/${repoName}.git`
78 | );
79 | }
80 |
81 | export async function duplicateGist(
82 | targetGistId: string,
83 | sourceGistId: string
84 | ) {
85 | const [, repo] = await ensureRepo(targetGistId);
86 | const token = await getToken();
87 |
88 | return pushRemote(
89 | repo,
90 | "duplicate",
91 | `https://${store.login}:${token}@gist.github.com/${sourceGistId}.git`
92 | );
93 | }
94 |
95 | export async function cloneGistToDirectory(
96 | gistId: string,
97 | parentDirectory: string,
98 | directoryName: string
99 | ) {
100 | const token = await getToken();
101 | const remote = `https://${store.login}:${token}@gist.github.com/${gistId}.git`;
102 | const targetPath = path.join(parentDirectory, directoryName);
103 |
104 | await git(parentDirectory).silent(true).clone(remote, directoryName);
105 |
106 | return targetPath;
107 | }
108 |
109 | async function pushRemote(
110 | repo: SimpleGit,
111 | remoteName: string,
112 | remoteUrl: string
113 | ) {
114 | const remotes = await repo.getRemotes(false);
115 | if (
116 | (Array.isArray(remotes) &&
117 | !remotes.find((ref) => ref.name === remoteName)) ||
118 | // @ts-ignore
119 | !remotes[remoteName]
120 | ) {
121 | await repo.addRemote(remoteName, remoteUrl);
122 | }
123 |
124 | await repo.push(remoteName, "main", { "--force": null });
125 | await repo.removeRemote(remoteName);
126 | }
127 |
--------------------------------------------------------------------------------
/src/repos/comments/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Comment,
3 | CommentAuthorInformation,
4 | CommentController,
5 | CommentMode,
6 | comments,
7 | CommentThread,
8 | CommentThreadCollapsibleState,
9 | ExtensionContext,
10 | MarkdownString,
11 | Range,
12 | Uri,
13 | window,
14 | workspace
15 | } from "vscode";
16 | import { EXTENSION_NAME } from "../../constants";
17 | import { GistComment } from "../../store";
18 | import { getCurrentUser } from "../../store/auth";
19 | import { RepoFileSystemProvider } from "../fileSystem";
20 | import { store } from "../store";
21 | import { getRepoComments } from "./actions";
22 | import { registerCommentCommands } from "./commands";
23 |
24 | export class RepoCommitComment implements Comment {
25 | public contextValue: string;
26 | public body: string | MarkdownString;
27 | public id: string;
28 | public label: string;
29 | public mode: CommentMode = CommentMode.Preview;
30 | public author: CommentAuthorInformation;
31 |
32 | constructor(
33 | comment: GistComment,
34 | public repo: string,
35 | public parent: CommentThread,
36 | public currentUser: string
37 | ) {
38 | this.id = comment.id;
39 | this.body = new MarkdownString(comment.body);
40 |
41 | this.author = {
42 | name: comment.user.login,
43 | iconPath: Uri.parse(comment.user.avatar_url)
44 | };
45 |
46 | this.label = comment.author_association === "OWNER" ? "Owner" : "";
47 | this.contextValue = currentUser === comment.user.login ? "canEdit" : "";
48 | }
49 | }
50 |
51 | function commentRange({ line }: any) {
52 | return new Range(line - 1, 0, line - 1, 0);
53 | }
54 |
55 | let controller: CommentController | undefined;
56 | async function checkForComments(uri: Uri) {
57 | const [repo, path] = RepoFileSystemProvider.getFileInfo(uri)!;
58 | const repoComments = await getRepoComments(repo);
59 | const fileComments = repoComments.filter(
60 | (comment: any) => comment.path === path
61 | );
62 |
63 | const currentUser = getCurrentUser();
64 |
65 | if (fileComments.length > 0) {
66 | fileComments.forEach((comment: any) => {
67 | const thread = controller!.createCommentThread(
68 | uri,
69 | commentRange(comment),
70 | []
71 | );
72 |
73 | thread.collapsibleState = CommentThreadCollapsibleState.Expanded;
74 | thread.canReply = false;
75 | thread.comments = [
76 | new RepoCommitComment(comment, repo, thread, currentUser)
77 | ];
78 | });
79 | }
80 | }
81 |
82 | export async function registerCommentController(context: ExtensionContext) {
83 | workspace.onDidOpenTextDocument(async (document) => {
84 | if (RepoFileSystemProvider.isRepoDocument(document)) {
85 | if (!controller) {
86 | controller = comments.createCommentController(
87 | `${EXTENSION_NAME}:repo`,
88 | "GistPad"
89 | );
90 |
91 | controller.commentingRangeProvider = {
92 | provideCommentingRanges: (document) => {
93 | if (
94 | // We don't want to register two comments providers at the same time
95 | !store.isInCodeTour &&
96 | RepoFileSystemProvider.isRepoDocument(document)
97 | ) {
98 | return [new Range(0, 0, document.lineCount, 0)];
99 | }
100 | }
101 | };
102 | }
103 |
104 | checkForComments(document.uri);
105 | }
106 | });
107 |
108 | workspace.onDidCloseTextDocument((e) => {
109 | if (
110 | RepoFileSystemProvider.isRepoDocument(e) &&
111 | !window.visibleTextEditors.find((editor) =>
112 | RepoFileSystemProvider.isRepoDocument(editor.document)
113 | )
114 | ) {
115 | controller!.dispose();
116 | controller = undefined;
117 | }
118 | });
119 |
120 | registerCommentCommands(context);
121 | }
122 |
--------------------------------------------------------------------------------
/src/repos/wiki/commands.ts:
--------------------------------------------------------------------------------
1 | import { commands, ExtensionContext, window, workspace } from "vscode";
2 | import { EXTENSION_NAME } from "../../constants";
3 | import { stringToByteArray, withProgress } from "../../utils";
4 | import { RepoFileSystemProvider } from "../fileSystem";
5 | import { Repository, store } from "../store";
6 | import { RepositoryFileNode, RepositoryNode } from "../tree/nodes";
7 | import { openRepoDocument } from "../utils";
8 | import { config } from "./config";
9 | import { getPageFilePath } from "./utils";
10 |
11 | import moment = require("moment");
12 | const { titleCase } = require("title-case");
13 |
14 | async function createWikiPage(
15 | name: string,
16 | repo: Repository,
17 | filePath: string
18 | ) {
19 | const title = titleCase(name);
20 | let fileHeading = `# ${title}
21 |
22 | `;
23 |
24 | const uri = RepoFileSystemProvider.getFileUri(repo.name, filePath);
25 | return workspace.fs.writeFile(uri, stringToByteArray(fileHeading));
26 | }
27 |
28 | export function registerCommands(context: ExtensionContext) {
29 | // This is a private command that handles dynamically
30 | // creating wiki documents, when the user auto-completes
31 | // a new document link that doesn't exist.
32 | context.subscriptions.push(
33 | commands.registerCommand(
34 | `${EXTENSION_NAME}._createWikiPage`,
35 | async (repo: Repository, name: string) => {
36 | const fileName = getPageFilePath(name);
37 | await createWikiPage(name, repo, fileName);
38 |
39 | // Automatically save the current, in order to ensure
40 | // the newly created backlink is discovered.
41 | await window.activeTextEditor?.document.save();
42 | }
43 | )
44 | );
45 |
46 | context.subscriptions.push(
47 | commands.registerCommand(
48 | `${EXTENSION_NAME}.addWikiPage`,
49 | async (node?: RepositoryNode | RepositoryFileNode) => {
50 | const repo = node?.repo || store.wiki!;
51 | const repoName = repo.name;
52 |
53 | const input = window.createInputBox();
54 | input.title = `Add wiki page (${repoName})`;
55 | input.prompt = "Enter the name of the new page you'd like to create";
56 |
57 | input.onDidAccept(async () => {
58 | input.hide();
59 |
60 | if (input.value) {
61 | const path = getPageFilePath(input.value);
62 | const filePath =
63 | node instanceof RepositoryFileNode
64 | ? `${node.file.path}/${path}`
65 | : path;
66 |
67 | await withProgress("Adding new page...", async () =>
68 | createWikiPage(input.value, repo, filePath)
69 | );
70 | openRepoDocument(repoName, filePath);
71 | }
72 | });
73 |
74 | input.show();
75 | }
76 | )
77 | );
78 |
79 | context.subscriptions.push(
80 | commands.registerCommand(
81 | `${EXTENSION_NAME}.openTodayPage`,
82 | async (node?: RepositoryNode, displayProgress: boolean = true) => {
83 | const sharedMoment = moment();
84 |
85 | const filenameFormat = (config.dailyFilenameFormat as string) || "YYYY-MM-DD";
86 | const fileName = sharedMoment.format(filenameFormat);
87 |
88 | const filePath = getPageFilePath(fileName);
89 |
90 | const titleFormat = workspace
91 | .getConfiguration(EXTENSION_NAME)
92 | .get("wikis.daily.titleFormat", "LL");
93 |
94 | const repo = node?.repo || store.wiki!;
95 | const repoName = repo.name;
96 |
97 | const pageTitle = sharedMoment.format(titleFormat);
98 |
99 | const uri = RepoFileSystemProvider.getFileUri(repoName, filePath);
100 |
101 | const [, file] = RepoFileSystemProvider.getRepoInfo(uri)!;
102 |
103 | if (!file) {
104 | const writeFile = async () =>
105 | createWikiPage(pageTitle, repo, filePath);
106 |
107 | if (displayProgress) {
108 | await withProgress("Adding new page...", writeFile);
109 | } else {
110 | await writeFile();
111 | }
112 | }
113 |
114 | openRepoDocument(repoName, filePath);
115 | }
116 | )
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/src/swings/index.ts:
--------------------------------------------------------------------------------
1 | import { reaction } from "mobx";
2 | import * as vscode from "vscode";
3 | import { extensions } from "vscode";
4 | import { EXTENSION_NAME, SWING_FILE } from "../constants";
5 | import { Gist, store } from "../store";
6 | import { newGist } from "../store/actions";
7 | import { GistNode } from "../tree/nodes";
8 | import { fileNameToUri, isOwnedGist, updateGistTags } from "../utils";
9 |
10 | class CodeSwingTemplateProvider {
11 | public _onDidChangeTemplate = new vscode.EventEmitter();
12 | public onDidChangeTemplates = this._onDidChangeTemplate.event;
13 |
14 | public provideTemplates() {
15 | return store.gists
16 | .concat(store.starredGists)
17 | .filter((gist: Gist) => gist.type === "code-swing-template")
18 | .map((gist: Gist) => ({
19 | title: gist.description,
20 | description: isOwnedGist(gist.id) ? "" : gist.owner,
21 | files: Object.keys(gist.files).map((file) => ({
22 | filename: file,
23 | content: gist.files[file].content
24 | }))
25 | }));
26 | }
27 | }
28 |
29 | const templateProvider = new CodeSwingTemplateProvider();
30 |
31 | function loadSwingManifests() {
32 | store.gists
33 | .concat(store.starredGists)
34 | .concat(store.archivedGists)
35 | .forEach(async (gist) => {
36 | const manifest = gist.files[SWING_FILE];
37 | if (manifest) {
38 | await vscode.workspace.fs.readFile(fileNameToUri(gist.id, SWING_FILE));
39 | updateGistTags(gist);
40 | }
41 | });
42 |
43 | templateProvider._onDidChangeTemplate.fire();
44 | }
45 |
46 | async function newSwing(isPublic: boolean) {
47 | const inputBox = vscode.window.createInputBox();
48 | inputBox.title = "Create new " + (isPublic ? "" : "secret ") + "swing";
49 | inputBox.placeholder = "Specify the description of the swing";
50 |
51 | inputBox.onDidAccept(() => {
52 | inputBox.hide();
53 |
54 | swingApi.newSwing(
55 | async (files: { filename: string; contents?: string }[]) => {
56 | const gist = await newGist(files, isPublic, inputBox.value, false);
57 | return fileNameToUri(gist.id);
58 | },
59 | inputBox.title
60 | );
61 | });
62 |
63 | inputBox.show();
64 | }
65 |
66 | let swingApi: any;
67 | export async function registerCodeSwingModule(
68 | context: vscode.ExtensionContext
69 | ) {
70 | reaction(
71 | () => [store.isSignedIn, store.isLoading],
72 | ([isSignedIn, isLoading]) => {
73 | if (isSignedIn && !isLoading) {
74 | loadSwingManifests();
75 | }
76 | }
77 | );
78 |
79 | const extension = extensions.getExtension("codespaces-contrib.codeswing");
80 | if (!extension) {
81 | return;
82 | }
83 |
84 | vscode.commands.executeCommand(
85 | "setContext",
86 | "gistpad:codeSwingEnabled",
87 | true
88 | );
89 |
90 | if (!extension.isActive) {
91 | await extension.activate();
92 | }
93 |
94 | swingApi = extension.exports;
95 |
96 | context.subscriptions.push(
97 | vscode.commands.registerCommand(
98 | `${EXTENSION_NAME}.newSwing`,
99 | newSwing.bind(null, true)
100 | )
101 | );
102 |
103 | context.subscriptions.push(
104 | vscode.commands.registerCommand(
105 | `${EXTENSION_NAME}.newSecretSwing`,
106 | newSwing.bind(null, false)
107 | )
108 | );
109 |
110 | context.subscriptions.push(
111 | vscode.commands.registerCommand(
112 | `${EXTENSION_NAME}.openGistInBlocks`,
113 | async (node: GistNode) => {
114 | vscode.env.openExternal(
115 | vscode.Uri.parse(
116 | `https://bl.ocks.org/${node.gist.owner.login}/${node.gist.id}`
117 | )
118 | );
119 | }
120 | )
121 | );
122 |
123 | context.subscriptions.push(
124 | vscode.commands.registerCommand(
125 | `${EXTENSION_NAME}.exportGistToCodePen`,
126 | async (node: GistNode) => {
127 | const uri = fileNameToUri(node.gist.id);
128 | swingApi.exportSwingToCodePen(uri);
129 | }
130 | )
131 | );
132 |
133 | swingApi.registerTemplateProvider("gist", templateProvider, {
134 | title: "Gists",
135 | description:
136 | "Templates provided by your own gists, and gists you've starred."
137 | });
138 | }
139 |
--------------------------------------------------------------------------------
/src/uriHandler.ts:
--------------------------------------------------------------------------------
1 | import { when } from "mobx";
2 | import { URLSearchParams } from "url";
3 | import * as vscode from "vscode";
4 | import { EXTENSION_ID, EXTENSION_NAME } from "./constants";
5 | import { store as repoStore } from "./repos/store";
6 | import { openRepo } from "./repos/store/actions";
7 | import { store } from "./store";
8 | import { followUser, openTodayNote } from "./store/actions";
9 | import { ensureAuthenticated as ensureAuthenticatedInternal } from "./store/auth";
10 | import { decodeDirectoryName, fileNameToUri, openGist, openGistFile, withProgress } from "./utils";
11 |
12 | const OPEN_PATH = "/open";
13 | const GIST_PARAM = "gist";
14 | const REPO_PARAM = "repo";
15 | const FILE_PARAM = "file";
16 |
17 | async function ensureAuthenticated() {
18 | await when(() => store.isSignedIn, { timeout: 3000 });
19 | await ensureAuthenticatedInternal();
20 |
21 | if (!store.isSignedIn) throw new Error();
22 | }
23 |
24 | async function handleFollowRequest(query: URLSearchParams) {
25 | await ensureAuthenticated();
26 |
27 | const user = query.get("user");
28 | if (user) {
29 | followUser(user);
30 | vscode.commands.executeCommand("workbench.view.extension.gistpad");
31 | }
32 | }
33 |
34 | async function handleOpenRequest(query: URLSearchParams) {
35 | const gistId = query.get(GIST_PARAM);
36 | const repoName = query.get(REPO_PARAM);
37 | const file = query.get(FILE_PARAM);
38 | const openAsWorkspace = query.get("workspace") !== null;
39 |
40 | if (gistId) {
41 | if (file) {
42 | const uri = fileNameToUri(gistId, decodeDirectoryName(file));
43 | openGistFile(uri)
44 | } else {
45 | openGist(gistId, !!openAsWorkspace);
46 | }
47 | } else if (repoName) {
48 | openRepo(repoName, true);
49 | }
50 | }
51 |
52 | async function handleDailyRequest() {
53 | withProgress("Opening daily note...", async () => {
54 | await ensureAuthenticated();
55 |
56 | // We need to wait for the gists to fully load
57 | // so that we know whether there's already a
58 | // daily gist or not, before opening it.
59 | await when(() => !store.isLoading);
60 | await vscode.commands.executeCommand("gistpad.gists.focus");
61 | await openTodayNote(false);
62 | });
63 | }
64 |
65 | async function handleTodayRequest() {
66 | withProgress("Opening today page...", async () => {
67 | await ensureAuthenticated();
68 |
69 | await when(
70 | () => repoStore.wiki !== undefined && !repoStore.wiki.isLoading,
71 | { timeout: 15000 }
72 | );
73 |
74 | if (repoStore.wiki) {
75 | await vscode.commands.executeCommand("gistpad.repos.focus");
76 | await vscode.commands.executeCommand(
77 | `${EXTENSION_NAME}.openTodayPage`,
78 | null,
79 | false
80 | );
81 | } else {
82 | if (
83 | await vscode.window.showErrorMessage(
84 | "You don't currently have a wiki repo. Create or open one, then try again.",
85 | "Open repo"
86 | )
87 | ) {
88 | vscode.commands.executeCommand(`${EXTENSION_NAME}.openRepository`);
89 | }
90 | }
91 | });
92 | }
93 |
94 | export function createGistPadOpenUrl(gistId: string, file?: string) {
95 | const fileParam = file ? `&${FILE_PARAM}=${file}` : "";
96 | return `vscode://${EXTENSION_ID}${OPEN_PATH}?${GIST_PARAM}=${gistId}${fileParam}`;
97 | }
98 |
99 | export function createGistPadWebUrl(gistId: string, file: string = "README.md") {
100 | const path = file && file !== "README.md" ? `/${file}` : "";
101 | return `https://gistpad.dev/#share/${gistId}${path}`;
102 | }
103 |
104 | class GistPadPUriHandler implements vscode.UriHandler {
105 | public async handleUri(uri: vscode.Uri) {
106 | const query = new URLSearchParams(uri.query);
107 | switch (uri.path) {
108 | case OPEN_PATH:
109 | return await handleOpenRequest(query);
110 | case "/follow":
111 | return await handleFollowRequest(query);
112 | case "/daily":
113 | return await handleDailyRequest();
114 | case "/today":
115 | return await handleTodayRequest();
116 | }
117 | }
118 | }
119 |
120 | export function registerProtocolHandler() {
121 | if (typeof vscode.window.registerUriHandler === "function") {
122 | vscode.window.registerUriHandler(new GistPadPUriHandler());
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/abstractions/node/images/clipboardToImageBuffer.ts:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import { spawn } from "child_process";
4 | import * as fs from "fs";
5 | import * as os from "os";
6 | import * as path from "path";
7 | import { randomInt } from "./utils/randomInt";
8 |
9 | function createImagePath() {
10 | return path.join(os.tmpdir(), `${randomInt()}_${randomInt()}.png`);
11 | }
12 |
13 | export class ClipboardToImageBuffer {
14 | public async getImageBits(): Promise {
15 | const platform = process.platform;
16 | const imagePath = createImagePath();
17 |
18 | switch (platform) {
19 | case "win32":
20 | return await this.getImageFromClipboardWin(imagePath);
21 | case "darwin":
22 | return await this.getImageFromClipboardMac(imagePath);
23 | case "linux":
24 | return await this.getImageFromClipboardLinux(imagePath);
25 | default:
26 | throw new Error(`Not supported platform "${platform}".`);
27 | }
28 | }
29 |
30 | private getImageFromClipboardWin(imagePath: string): Promise {
31 | return new Promise((res, rej) => {
32 | const scriptPath = path.join(__dirname, "./scripts/win.ps1");
33 |
34 | let command =
35 | "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
36 | const powershellExisted = fs.existsSync(command);
37 | if (!powershellExisted) {
38 | command = "powershell";
39 | }
40 |
41 | const powershell = spawn(command, [
42 | "-noprofile",
43 | "-noninteractive",
44 | "-nologo",
45 | "-sta",
46 | "-executionpolicy",
47 | "unrestricted",
48 | "-windowstyle",
49 | "hidden",
50 | "-file",
51 | scriptPath,
52 | imagePath
53 | ]);
54 |
55 | powershell.on("error", function(e: any) {
56 | const { code } = e as any;
57 |
58 | rej(
59 | code === "ENOENT"
60 | ? "The powershell command is not in you PATH environment variables.Please add it and retry."
61 | : e
62 | );
63 | });
64 |
65 | powershell.stdout.on("data", function(data: Buffer) {
66 | const filePath = data.toString().trim();
67 |
68 | if (filePath === "no image") {
69 | rej("No image found.");
70 | }
71 |
72 | const binary = fs.readFileSync(filePath);
73 |
74 | if (!binary) {
75 | rej("No temporary image file read");
76 | }
77 |
78 | res(binary);
79 | fs.unlinkSync(imagePath);
80 | });
81 | });
82 | }
83 |
84 | private getImageFromClipboardMac(imagePath: string): Promise {
85 | return new Promise((res, rej) => {
86 | const scriptPath = path.join(__dirname, "./scripts/mac.applescript");
87 | const ascript = spawn("osascript", [scriptPath, imagePath]);
88 | ascript.on("error", (e: any) => {
89 | rej(e);
90 | });
91 |
92 | ascript.stdout.on("data", (data: Buffer) => {
93 | const filePath = data.toString().trim();
94 | if (filePath === "no image") {
95 | return rej("No image found.");
96 | }
97 |
98 | const binary = fs.readFileSync(filePath);
99 | if (!binary) {
100 | return rej("No temporary image file read.");
101 | }
102 |
103 | fs.unlinkSync(imagePath);
104 | res(binary);
105 | });
106 | });
107 | }
108 |
109 | private getImageFromClipboardLinux(imagePath: string): Promise {
110 | return new Promise((res, rej) => {
111 | const scriptPath = path.join(__dirname, "./scripts/linux.sh");
112 |
113 | const ascript = spawn("sh", [scriptPath, imagePath]);
114 | ascript.on("error", function(e: any) {
115 | rej(e);
116 | });
117 |
118 | ascript.stdout.on("data", (data: Buffer) => {
119 | const result = data.toString().trim();
120 | if (result === "no xclip") {
121 | const message = "You need to install xclip command first.";
122 | return rej(message);
123 | }
124 |
125 | if (result === "no image") {
126 | const message = "Cannot get image in the clipboard.";
127 | return rej(message);
128 | }
129 |
130 | const binary = fs.readFileSync(result);
131 |
132 | if (!binary) {
133 | return rej("No temporary image file read.");
134 | }
135 |
136 | res(binary);
137 | fs.unlinkSync(imagePath);
138 | });
139 | });
140 | }
141 | }
142 |
143 | export const clipboardToImageBuffer = new ClipboardToImageBuffer();
144 |
--------------------------------------------------------------------------------
/src/store/auth.ts:
--------------------------------------------------------------------------------
1 | import {
2 | authentication,
3 | AuthenticationSession,
4 | commands,
5 | window
6 | } from "vscode";
7 | import { store } from ".";
8 | import { EXTENSION_NAME } from "../constants";
9 | import { refreshGists } from "./actions";
10 | const GitHub = require("github-base");
11 |
12 | let loginSession: string | undefined;
13 |
14 | export function getCurrentUser() {
15 | return store.login;
16 | }
17 |
18 | const STATE_CONTEXT_KEY = `${EXTENSION_NAME}:state`;
19 | const STATE_SIGNED_IN = "SignedIn";
20 | const STATE_SIGNED_OUT = "SignedOut";
21 |
22 | const GIST_SCOPE = "gist";
23 | const REPO_SCOPE = "repo";
24 | const DELETE_REPO_SCOPE = "delete_repo";
25 |
26 | // TODO: Replace github-base with octokit
27 | export async function getApi(newToken?: string) {
28 | const token = newToken || (await getToken());
29 | return new GitHub({ token });
30 | }
31 |
32 | const TOKEN_RESPONSE = "Sign in";
33 | export async function ensureAuthenticated() {
34 | if (store.isSignedIn) {
35 | return;
36 | }
37 |
38 | const response = await window.showErrorMessage(
39 | "You need to sign-in with GitHub to perform this operation.",
40 | TOKEN_RESPONSE
41 | );
42 | if (response === TOKEN_RESPONSE) {
43 | await signIn();
44 | }
45 | }
46 |
47 | async function getSession(
48 | isInteractiveSignIn: boolean = false,
49 | includeDeleteRepoScope: boolean = false
50 | ) {
51 | const scopes = [GIST_SCOPE, REPO_SCOPE];
52 | if (includeDeleteRepoScope) {
53 | scopes.push(DELETE_REPO_SCOPE);
54 | }
55 |
56 | try {
57 | if (isInteractiveSignIn) {
58 | isSigningIn = true;
59 | }
60 |
61 | const session = await authentication.getSession("github", scopes, {
62 | createIfNone: isInteractiveSignIn
63 | });
64 |
65 | if (session) {
66 | loginSession = session?.id;
67 | }
68 |
69 | isSigningIn = false;
70 |
71 | return session;
72 | } catch { }
73 | }
74 |
75 | export async function getToken() {
76 | return store.token;
77 | }
78 |
79 | async function markUserAsSignedIn(
80 | session: AuthenticationSession,
81 | refreshUI: boolean = true
82 | ) {
83 | loginSession = session.id;
84 |
85 | store.token = session.accessToken;
86 | store.isSignedIn = true;
87 | store.login = session.account.label;
88 | store.canCreateRepos = session.scopes.includes(REPO_SCOPE);
89 | store.canDeleteRepos = session.scopes.includes(DELETE_REPO_SCOPE);
90 |
91 | if (refreshUI) {
92 | commands.executeCommand("setContext", STATE_CONTEXT_KEY, STATE_SIGNED_IN);
93 | await refreshGists();
94 | }
95 | }
96 |
97 | function markUserAsSignedOut() {
98 | loginSession = undefined;
99 |
100 | store.login = "";
101 | store.isSignedIn = false;
102 |
103 | commands.executeCommand("setContext", STATE_CONTEXT_KEY, STATE_SIGNED_OUT);
104 | }
105 |
106 | let isSigningIn = false;
107 | export async function signIn() {
108 | const session = await getSession(true);
109 |
110 | if (session) {
111 | window.showInformationMessage(
112 | "You're successfully signed in and can now manage your GitHub gists and repositories!"
113 | );
114 | await markUserAsSignedIn(session);
115 | return true;
116 | }
117 | }
118 |
119 | export async function elevateSignin() {
120 | const session = await getSession(true, true);
121 |
122 | if (session) {
123 | await markUserAsSignedIn(session, false);
124 | return true;
125 | }
126 | }
127 |
128 | async function attemptSilentSignin(refreshUI: boolean = true) {
129 | const session = await getSession();
130 |
131 | if (session) {
132 | await markUserAsSignedIn(session, refreshUI);
133 | } else {
134 | await markUserAsSignedOut();
135 | }
136 | }
137 |
138 | export async function initializeAuth() {
139 | authentication.onDidChangeSessions(async (e) => {
140 | if (e.provider.id === "github") {
141 | // @ts-ignore
142 | if (e.added.length > 0) {
143 | // This session was added based on a GistPad-triggered
144 | // sign-in, and so we don't need to do anything further to process it.
145 | if (isSigningIn) {
146 | isSigningIn = false;
147 | return;
148 | }
149 |
150 | // The end-user just signed in to Gist via the
151 | // VS Code account UI, and therefore, we need
152 | // to grab the session token/etc.
153 | await attemptSilentSignin();
154 | // @ts-ignore
155 | } else if (e.changed.length > 0 && e.changed.includes(loginSession)) {
156 | // TODO: Validate when this actually fires
157 | await attemptSilentSignin(false);
158 | }
159 | // @ts-ignore
160 | else if (e.removed.length > 0 && e.removed.includes(loginSession)) {
161 | // TODO: Implement sign out support
162 | }
163 | }
164 | });
165 |
166 | await attemptSilentSignin();
167 | }
168 |
--------------------------------------------------------------------------------