Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .prettierrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ export default {
printWidth: 120,
overrides: [
{
files: ['*.yml', '*.yaml', '*.json'],
files: ['*.yml', '*.yaml', '*.json', '*.jsonc'],
options: {
useTabs: false,
},
},
{
files: ['*.jsonc'],
options: {
trailingComma: 'none',
},
},
],
};
2 changes: 1 addition & 1 deletion extensions/github1s/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"onFileSystem:gitlab1s",
"onFileSystem:bitbucket1s",
"onFileSystem:npmjs1s",
"onFileSystem:ossinsight",
"onFileSystem:discovery",
"onCommand:remoteHub.openRepository"
],
"browser": "./dist/extension",
Expand Down
49 changes: 49 additions & 0 deletions extensions/github1s/src/adapters/discovery/data-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { getBrowserUrl } from '@/helpers/context';
import { decorate, memorize } from '@/helpers/func';
import { DataSource, Directory, File, FileType } from '../types';
import { createDiscoveryPages } from './templates';
import type { DiscoverySnapshot } from './types';

export class DiscoveryDataSource extends DataSource {
private readonly encoder = new TextEncoder();

@decorate(memorize)
private async getPages() {
const response = await fetch('https://discovery.github1s.com/github/latest.json', {
signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
throw new Error(`Unable to load Discovery: HTTP ${response.status}.`);
}
const snapshot: DiscoverySnapshot = await response.json();
if (
!Array.isArray(snapshot.collections) ||
!snapshot.collections.every((collection) => collection?.kind === 'topic' || collection?.kind === 'curated') ||
!snapshot.repositories ||
typeof snapshot.repositories !== 'object' ||
!Number.isFinite(Date.parse(snapshot.generated_at))
) {
throw new Error('Unable to load Discovery: invalid snapshot.');
}
return getBrowserUrl().then((browserUrl: string) => createDiscoveryPages(snapshot, new URL(browserUrl).origin));
}

async provideDirectory(repo: string, ref: string, path: string, recursive = false): Promise<Directory | null> {
const prefix = path.replace(/\/+$/, '') + '/';
if (prefix !== '/' && prefix !== '/topics/') {
return null;
}
const entries: Directory['entries'] = prefix === '/' ? [{ path: '/topics', type: FileType.Directory }] : [];
for (const filePath of (await this.getPages()).keys()) {
if (filePath.startsWith(prefix) && (recursive || !filePath.slice(prefix.length).includes('/'))) {
entries.push({ path: filePath, type: FileType.File });
}
}
return { truncated: false, entries };
}

async provideFile(repo: string, ref: string, path: string): Promise<File | null> {
const render = (await this.getPages()).get(path);
return render ? { content: this.encoder.encode(render()) } : null;
}
}
18 changes: 18 additions & 0 deletions extensions/github1s/src/adapters/discovery/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Adapter, PlatformName } from '../types';
import { DiscoveryDataSource } from './data-source';
import { DiscoveryRouterParser } from './router-parser';

export class DiscoveryAdapter implements Adapter {
public readonly scheme = 'discovery';
public readonly platformName = PlatformName.GitHub;
private readonly dataSource = new DiscoveryDataSource();
private readonly routerParser = new DiscoveryRouterParser();

resolveDataSource() {
return this.dataSource;
}

resolveRouterParser() {
return this.routerParser;
}
}
43 changes: 43 additions & 0 deletions extensions/github1s/src/adapters/discovery/router-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @file router parser
* @author netcon
*/

import { parsePath } from 'history';
import queryString from 'query-string';
import * as adapterTypes from '../types';
import { GitHub1sRouterParser } from '../github1s/router-parser';
import { normalizePath } from '@/helpers/util';

export class DiscoveryRouterParser extends GitHub1sRouterParser {
async parsePath(path: string): Promise<adapterTypes.RouterState> {
const { path: pathsOrNull } = queryString.parse((parsePath(path).search || '').slice(1));
const filePath = normalizePath((Array.isArray(pathsOrNull) ? pathsOrNull[0] : pathsOrNull) || '');
const pageType = filePath.endsWith('.md') ? adapterTypes.PageType.Blob : adapterTypes.PageType.Tree;
return { pageType, repo: '', ref: '', filePath };
}

buildTreePath(repo: string, ref?: string, filePath?: string) {
return repo ? super.buildTreePath(repo, ref, filePath) : '/';
}

buildBlobPath(repo: string, ref: string, filePath: string, startLine?: number, endLine?: number) {
return repo ? super.buildBlobPath(repo, ref, filePath, startLine, endLine) : '/';
}

buildCommitListPath(repo: string) {
return repo ? super.buildCommitListPath(repo) : '/';
}

buildCommitPath(repo: string, commitSha: string) {
return repo ? super.buildCommitPath(repo, commitSha) : '/';
}

buildCodeReviewListPath(repo: string) {
return repo ? super.buildCodeReviewListPath(repo) : '/';
}

buildCodeReviewPath(repo: string, codeReviewId: string) {
return repo ? super.buildCodeReviewPath(repo, codeReviewId) : '/';
}
}
152 changes: 152 additions & 0 deletions extensions/github1s/src/adapters/discovery/templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { DiscoveryCollection, DiscoverySnapshot, Repository } from './types';

interface CollectionPresentation {
title: string;
description: string;
icon: string;
}

// Page copy can change independently of persisted collection snapshots.
const curatedPresentations: Record<string, CollectionPresentation> = {
'new-projects': {
title: 'New Projects',
description: 'Recently created repositories.',
icon: '🌱',
},
'hidden-gems': {
title: 'Hidden Gems',
description: 'Lesser-known repositories with recent development activity.',
icon: '💎',
},
'most-starred': {
title: 'Most Starred',
description: 'Repositories with the highest total star counts on GitHub.',
icon: '⭐',
},
};

const getCollectionPresentation = ({ id, kind }: DiscoveryCollection): CollectionPresentation => {
if (kind === 'topic') {
return { title: id, description: `Repositories tagged ${id} on GitHub.`, icon: '🏷️' };
}
if (Object.prototype.hasOwnProperty.call(curatedPresentations, id)) {
return curatedPresentations[id];
}
return { title: id, description: 'A curated selection of GitHub repositories.', icon: '📚' };
};

// Repository descriptions are plain text, even when they contain Markdown or HTML.
const escapeText = (text: string) =>
text
.replace(/\s+/g, ' ')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/[\\`*_{}[\]()#+.!|~-]/g, '\\$&');

const formatCount = (count: number) => count.toLocaleString('en-US');
const collectionPath = (collection: DiscoveryCollection) =>
`/${collection.kind === 'topic' ? 'topics/' : ''}${collection.id}.md`;
const collectionLink = (collection: DiscoveryCollection) =>
`[${escapeText(getCollectionPresentation(collection).title)}](${collectionPath(collection)})`;
const homeLink = '[← Discovery](/README.md)';

// Keep content in Markdown; use CSS for typography and section boundaries.
const renderPage = (content: string) => `<style>
body { max-width: 900px; margin: 0 auto; }
h2:has(a), h3:has(a) { margin-bottom: 8px; padding-bottom: 0; border: 0; }
small { color: var(--vscode-descriptionForeground); }
hr.section-break { height: 0; margin: 36px 0 28px; border: 0; border-top: 3px double var(--vscode-descriptionForeground); }
</style>

${content}
`;

const getRepositories = (snapshot: DiscoverySnapshot, collection: DiscoveryCollection): Repository[] =>
collection.repository_ids.map((id) => snapshot.repositories[id]).filter((repo): repo is Repository => !!repo);
const renderUpdatedAt = (snapshot: DiscoverySnapshot) =>
`Updated ${new Date(snapshot.generated_at).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
const renderMeta = (text: string) => `<small>${text}</small>`;

const renderRepository = (repo: Repository, origin: string, heading: '##' | '###') => {
const path = repo.full_name.split('/').map(encodeURIComponent).join('/');
const metadata = [
repo.language && `**${escapeText(repo.language)}**`,
repo.stargazers_count !== null &&
`★ ${formatCount(repo.stargazers_count)} ${repo.stargazers_count === 1 ? 'star' : 'stars'}`,
repo.forks_count !== null && `${formatCount(repo.forks_count)} ${repo.forks_count === 1 ? 'fork' : 'forks'}`,
repo.pushed_at && `Last push: ${escapeText(repo.pushed_at.slice(0, 10))}`,
`[GitHub ↗](https://github.com/${path})`,
].filter(Boolean);
return `${heading} [${escapeText(repo.full_name)}](${origin}/${path})

${repo.description ? escapeText(repo.description) : 'No description provided.'}
${renderMeta(metadata.join(' · '))}`;
};

const renderRepositoryList = (repos: Repository[], origin: string, heading: '##' | '###') =>
repos.map((repo) => renderRepository(repo, origin, heading)).join('\n\n---\n\n') ||
'No repositories available in this collection.';

const renderCollectionPreview = (snapshot: DiscoverySnapshot, collection: DiscoveryCollection, origin: string) => {
const { title, description, icon } = getCollectionPresentation(collection);
const repos = getRepositories(snapshot, collection);
return `<hr class="section-break">

## ${icon} ${escapeText(title)}

${escapeText(description)}

${renderRepositoryList(repos.slice(0, 3), origin, '###')}

[View ${escapeText(title)} →](${collectionPath(collection)}) · ${formatCount(repos.length)} repositories`;
};

const renderHome = (snapshot: DiscoverySnapshot, origin: string) => {
const curated = snapshot.collections.filter((collection) => collection.kind === 'curated');
const topics = snapshot.collections.filter((collection) => collection.kind === 'topic');
const sections = curated.map((collection) => renderCollectionPreview(snapshot, collection, origin));
return renderPage(`# GitHub1s Discovery

Discover GitHub projects and explore their source code in your browser.

${renderMeta(`${formatCount(Object.keys(snapshot.repositories).length)} repositories · ${renderUpdatedAt(snapshot)}`)}

${topics.map(collectionLink).join(' | ')}

${sections.join('\n\n') || 'No collections available.'}

---

${renderMeta('Source: GitHub Search. Star and fork counts reflect the values at the time of collection.')}`);
};

const renderCollection = (snapshot: DiscoverySnapshot, collection: DiscoveryCollection, origin: string) => {
const { title, description, icon } = getCollectionPresentation(collection);
const repos = getRepositories(snapshot, collection);
const sort = collection.sort === 'stars' ? 'Stars, descending' : 'Recently updated first';
return renderPage(`${homeLink}

# ${icon} ${escapeText(title)}

${escapeText(description)}

${renderMeta(`${formatCount(repos.length)} repositories · Sort: ${sort} · ${renderUpdatedAt(snapshot)}`)}

${renderRepositoryList(repos, origin, '##')}

---

${homeLink}`);
};

export const createDiscoveryPages = (snapshot: DiscoverySnapshot, origin: string): Map<string, () => string> => {
const pages = new Map<string, () => string>([['/README.md', () => renderHome(snapshot, origin)]]);
for (const collection of snapshot.collections) {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(collection.id)) {
throw new Error(`Invalid Discovery collection ID: ${collection.id}`);
}
pages.set(collectionPath(collection), () => renderCollection(snapshot, collection, origin));
}
return pages;
};
1 change: 1 addition & 0 deletions extensions/github1s/src/adapters/discovery/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type { DiscoveryCollection, DiscoverySnapshot, Repository } from '../../../../../workers/discovery/src/types';
4 changes: 2 additions & 2 deletions extensions/github1s/src/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { GitHub1sAdapter } from './github1s';
import { GitLab1sAdapter } from './gitlab1s';
import { BitbucketAdapter } from './bitbucket1s';
import { Npmjs1sAdapter } from './npmjs1s';
import { OSSInsightAdapter } from './ossinsight';
import { DiscoveryAdapter } from './discovery';
import { Adapter, DataSource, PlatformName, RouterParser } from './types';

const emptyAdapter = {
Expand All @@ -25,7 +25,7 @@ export const registerAdapters = async (): Promise<void> => {
adapterManager.registerAdapter(new GitLab1sAdapter()),
adapterManager.registerAdapter(new BitbucketAdapter()),
adapterManager.registerAdapter(new Npmjs1sAdapter()),
adapterManager.registerAdapter(new OSSInsightAdapter()),
adapterManager.registerAdapter(new DiscoveryAdapter()),
]);
};

Expand Down
8 changes: 6 additions & 2 deletions extensions/github1s/src/adapters/ossinsight/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,13 @@ export type CollectionItem = {
name: string;
};

export const getCollections = memorize((): Promise<CollectionItem[]> => {
export const getCollections = memorize(async (): Promise<CollectionItem[]> => {
const requestUrl = `${OSSInsightEndpoint}/v1/collections/`;
return fetch(requestUrl).then(resolveDataFromResponse);
const response = await fetch(requestUrl);
if (!response.ok) {
throw new Error(`Unable to load OSSInsight collections: HTTP ${response.status}.`);
}
return resolveDataFromResponse(response);
});

export const getCollectionIdByName = async (collectionName: string): Promise<string | null> => {
Expand Down
7 changes: 2 additions & 5 deletions extensions/github1s/src/adapters/sourcegraph/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ type SupportedPlatform = 'github' | 'gitlab' | 'bitbucket';
export class SourcegraphDataSource extends DataSource {
private static instanceMap: Map<SupportedPlatform, SourcegraphDataSource> = new Map();
private refsPromiseMap: Map<string, Promise<{ branches: Branch[]; tags: Tag[] }>> = new Map();
private repositoryPromiseMap: Map<string, Promise<{ private: boolean; defaultBranch: string } | null>> = new Map();
private fileTypeMap: Map<string, FileType> = new Map(); // cache if path is a directory
private matchedRefsMap: Map<string, string[]> = new Map();
private textEncoder = new TextEncoder();
Expand Down Expand Up @@ -91,11 +90,9 @@ export class SourcegraphDataSource extends DataSource {
return this.fileTypeMap.get(mapKey) || FileType.File;
}

@decorate(memorize)
async provideRepository(repo: string) {
if (!this.repositoryPromiseMap.has(repo)) {
this.repositoryPromiseMap.set(repo, getRepository(this.buildRepository(repo)));
}
return this.repositoryPromiseMap.get(repo);
return getRepository(this.buildRepository(repo));
}

async provideFile(repo: string, ref: string, path: string): Promise<File> {
Expand Down
2 changes: 1 addition & 1 deletion extensions/github1s/src/commands/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const commandOpenRepository = async () => {
};

const commandOpenOnlineEditor = async () => {
const onlineEditorPath = ['github1s', 'ossinsight'].includes(getAdapter().scheme) ? '/editor' : '/';
const onlineEditorPath = ['github1s', 'ossinsight', 'discovery'].includes(getAdapter().scheme) ? '/editor' : '/';
const targetLink = vscode.Uri.parse((await router.href()) || '').with({ path: onlineEditorPath });
return vscode.commands.executeCommand('vscode.open', targetLink);
};
Expand Down
17 changes: 15 additions & 2 deletions extensions/github1s/src/helpers/func.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,26 @@ export const memorize = <T extends (...args: any[]) => any>(

const result = func.call(this, ...args);
cache.set(key, result);
// Retain pending and fulfilled results, but allow the next call to retry a failure.
if (result && typeof result.then === 'function') {
Promise.resolve(result).catch(() => cache.delete(key));
}
return result;
};
};

export const decorate = <F extends (...args: unknown[]) => unknown>(transformer: (func: F) => F) => {
return <T>(_target: T, _propertyKey: string, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value;
descriptor.value = transformer(originalMethod as F);
const originalMethod: F = descriptor.value;
// Stateful transformers, such as memorize, need a separate wrapper for each instance.
const methods = new WeakMap<object, F>();
descriptor.value = function (this: object, ...args: Parameters<F>): ReturnType<F> {
let method = methods.get(this);
if (!method) {
method = transformer(originalMethod);
methods.set(this, method);
}
return method.apply(this, args);
};
};
};
Loading
Loading