mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-07 09:38:45 +00:00
291 lines
9.0 KiB
JavaScript
291 lines
9.0 KiB
JavaScript
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { PREFS_FILE } from "./constants.mjs";
|
|
|
|
const MAX_RECENT_REPOSITORIES = 12;
|
|
const MAX_FAVORITE_REPOSITORIES = 3;
|
|
const MAX_PULL_FILTER_REPOSITORIES = 50;
|
|
const PLAYER_FONT_SIZES = new Set(["12px", "14px", "16px", "18px", "20px", "22px"]);
|
|
const PLAYER_FONTS = new Set(["system", "cascadia", "consolas"]);
|
|
const PLAYER_SPEEDS = new Set([0.5, 1, 1.5, 2]);
|
|
const PULL_ARTIFACT_FILTERS = new Set(["all", "with", "without"]);
|
|
const PULL_CI_FILTERS = new Set(["all", "failing", "passing", "pending", "none"]);
|
|
let preferenceSaveQueue = Promise.resolve();
|
|
let preferenceSaveSequence = 0;
|
|
const WINDOWS_RENAME_RETRY_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
|
|
|
|
export const DEFAULT_PREFS = Object.freeze({
|
|
account: null,
|
|
repository: "microsoft/aspire",
|
|
pullState: "open",
|
|
repositories: {
|
|
pinned: null,
|
|
favorites: [],
|
|
recent: ["microsoft/aspire"],
|
|
},
|
|
pullFilters: {},
|
|
explorer: {
|
|
sidebarCollapsed: false,
|
|
},
|
|
player: {
|
|
fontSize: "16px",
|
|
fontFamily: "system",
|
|
lineHeight: 1.2,
|
|
speed: 1,
|
|
},
|
|
});
|
|
|
|
export function normalizeRepository(value) {
|
|
const repository = String(value ?? "").trim().replace(/^https?:\/\/github\.com\//i, "");
|
|
const match = repository.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/);
|
|
if (!match) {
|
|
throw new Error("Repository must use the owner/name format.");
|
|
}
|
|
if ([match[1], match[2]].some((part) => part === "." || part === "..")) {
|
|
throw new Error("Repository owner and name cannot be dot segments.");
|
|
}
|
|
return `${match[1]}/${match[2]}`;
|
|
}
|
|
|
|
function normalizeStoredRepository(value) {
|
|
try {
|
|
return typeof value === "string" && value.trim() ? normalizeRepository(value) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeRepositoryList(value, limit) {
|
|
const repositories = [];
|
|
for (const item of Array.isArray(value) ? value : []) {
|
|
const repository = normalizeStoredRepository(item);
|
|
if (repository && !repositories.includes(repository)) repositories.push(repository);
|
|
if (repositories.length === limit) break;
|
|
}
|
|
return repositories;
|
|
}
|
|
|
|
function normalizePlayer(value) {
|
|
const lineHeight = Number(value?.lineHeight);
|
|
const speed = Number(value?.speed);
|
|
return {
|
|
fontSize: PLAYER_FONT_SIZES.has(value?.fontSize)
|
|
? value.fontSize
|
|
: DEFAULT_PREFS.player.fontSize,
|
|
fontFamily: PLAYER_FONTS.has(value?.fontFamily)
|
|
? value.fontFamily
|
|
: DEFAULT_PREFS.player.fontFamily,
|
|
lineHeight:
|
|
Number.isFinite(lineHeight) && lineHeight >= 1 && lineHeight <= 1.6
|
|
? Math.round(lineHeight * 10) / 10
|
|
: DEFAULT_PREFS.player.lineHeight,
|
|
speed: PLAYER_SPEEDS.has(speed) ? speed : DEFAULT_PREFS.player.speed,
|
|
};
|
|
}
|
|
|
|
function normalizeExplorer(value) {
|
|
return {
|
|
sidebarCollapsed: value?.sidebarCollapsed === true,
|
|
};
|
|
}
|
|
|
|
function normalizePullFilter(value) {
|
|
const author =
|
|
typeof value?.author === "string" && value.author.trim()
|
|
? value.author.trim().slice(0, 100)
|
|
: null;
|
|
const updatedAt = Number(value?.updatedAt);
|
|
return {
|
|
state: ["open", "closed", "all"].includes(value?.state) ? value.state : "open",
|
|
author,
|
|
artifacts: PULL_ARTIFACT_FILTERS.has(value?.artifacts) ? value.artifacts : "all",
|
|
ci: PULL_CI_FILTERS.has(value?.ci) ? value.ci : "all",
|
|
updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0,
|
|
};
|
|
}
|
|
|
|
function normalizePullFilters(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
const entries = [];
|
|
for (const [key, filter] of Object.entries(value)) {
|
|
const repository = normalizeStoredRepository(key);
|
|
if (!repository || !filter || typeof filter !== "object") continue;
|
|
entries.push([
|
|
repository.toLocaleLowerCase(),
|
|
normalizePullFilter(filter),
|
|
]);
|
|
}
|
|
return Object.fromEntries(
|
|
entries
|
|
.sort((left, right) => right[1].updatedAt - left[1].updatedAt)
|
|
.slice(0, MAX_PULL_FILTER_REPOSITORIES),
|
|
);
|
|
}
|
|
|
|
export function normalizePrefs(value) {
|
|
const prefs = value && typeof value === "object" ? value : {};
|
|
const repository =
|
|
normalizeStoredRepository(prefs.repository) ?? DEFAULT_PREFS.repository;
|
|
const pinned = normalizeStoredRepository(prefs.repositories?.pinned);
|
|
const favorites = normalizeRepositoryList(
|
|
prefs.repositories?.favorites,
|
|
MAX_FAVORITE_REPOSITORIES,
|
|
).filter((item) => item !== pinned);
|
|
return {
|
|
account: typeof prefs.account === "string" && prefs.account ? prefs.account : null,
|
|
repository,
|
|
pullState: ["open", "closed", "all"].includes(prefs.pullState)
|
|
? prefs.pullState
|
|
: DEFAULT_PREFS.pullState,
|
|
repositories: {
|
|
pinned,
|
|
favorites,
|
|
recent: normalizeRepositoryList(
|
|
[
|
|
repository,
|
|
...(Array.isArray(prefs.repositories?.recent)
|
|
? prefs.repositories.recent
|
|
: []),
|
|
],
|
|
MAX_RECENT_REPOSITORIES,
|
|
),
|
|
},
|
|
pullFilters: normalizePullFilters(prefs.pullFilters),
|
|
explorer: normalizeExplorer(prefs.explorer),
|
|
player: normalizePlayer(prefs.player),
|
|
};
|
|
}
|
|
|
|
export async function loadPrefs() {
|
|
try {
|
|
return normalizePrefs(JSON.parse(await readFile(PREFS_FILE, "utf8")));
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") {
|
|
return normalizePrefs();
|
|
}
|
|
if (error instanceof SyntaxError) {
|
|
throw new Error(`Preferences are not valid JSON: ${PREFS_FILE}`, { cause: error });
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function replacePreferencesFile(temporary) {
|
|
let lastError;
|
|
for (let attempt = 0; attempt < 7; attempt++) {
|
|
try {
|
|
await rename(temporary, PREFS_FILE);
|
|
return;
|
|
} catch (error) {
|
|
if (!WINDOWS_RENAME_RETRY_CODES.has(error?.code)) throw error;
|
|
lastError = error;
|
|
if (attempt === 6) break;
|
|
await delay(10 * 2 ** attempt);
|
|
}
|
|
}
|
|
try {
|
|
await rm(temporary, { force: true });
|
|
} catch (cleanupError) {
|
|
throw new AggregateError(
|
|
[lastError, cleanupError],
|
|
"Preferences could not be replaced or cleaned up.",
|
|
);
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
export async function savePrefs(value) {
|
|
const prefs = normalizePrefs(value);
|
|
const save = async () => {
|
|
await mkdir(dirname(PREFS_FILE), { recursive: true });
|
|
const temporary =
|
|
`${PREFS_FILE}.${process.pid}.${++preferenceSaveSequence}.tmp`;
|
|
await writeFile(temporary, `${JSON.stringify(prefs, null, 2)}\n`, "utf8");
|
|
await replacePreferencesFile(temporary);
|
|
return prefs;
|
|
};
|
|
const pending = preferenceSaveQueue.then(save, save);
|
|
preferenceSaveQueue = pending.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return pending;
|
|
}
|
|
|
|
export function rememberRepository(preferences, value) {
|
|
const repository = normalizeRepository(value);
|
|
preferences.repository = repository;
|
|
preferences.repositories ??= {};
|
|
preferences.repositories.recent = normalizeRepositoryList(
|
|
[
|
|
repository,
|
|
...(Array.isArray(preferences.repositories.recent)
|
|
? preferences.repositories.recent
|
|
: []),
|
|
],
|
|
MAX_RECENT_REPOSITORIES,
|
|
);
|
|
return repository;
|
|
}
|
|
|
|
export function setPinnedRepository(preferences, value) {
|
|
const repository = value == null || value === "" ? null : normalizeRepository(value);
|
|
preferences.repositories ??= {};
|
|
preferences.repositories.pinned = repository;
|
|
preferences.repositories.favorites = normalizeRepositoryList(
|
|
preferences.repositories.favorites,
|
|
MAX_FAVORITE_REPOSITORIES,
|
|
).filter((item) => item !== repository);
|
|
return repository;
|
|
}
|
|
|
|
export function setFavoriteRepository(preferences, value, favorite) {
|
|
const repository = normalizeRepository(value);
|
|
preferences.repositories ??= {};
|
|
const favorites = normalizeRepositoryList(
|
|
preferences.repositories.favorites,
|
|
MAX_FAVORITE_REPOSITORIES,
|
|
).filter((item) => item !== repository);
|
|
if (favorite) {
|
|
if (preferences.repositories.pinned === repository) {
|
|
throw new Error("The pinned repository is already available in quick switch.");
|
|
}
|
|
if (favorites.length >= MAX_FAVORITE_REPOSITORIES) {
|
|
throw new Error("You can favorite up to three repositories.");
|
|
}
|
|
favorites.unshift(repository);
|
|
}
|
|
preferences.repositories.favorites = favorites;
|
|
return favorites;
|
|
}
|
|
|
|
export function setPullFilterPreferences(preferences, repositoryValue, value) {
|
|
const repository = normalizeRepository(repositoryValue).toLocaleLowerCase();
|
|
preferences.pullFilters = normalizePullFilters({
|
|
...preferences.pullFilters,
|
|
[repository]: {
|
|
...preferences.pullFilters?.[repository],
|
|
...value,
|
|
updatedAt: Date.now(),
|
|
},
|
|
});
|
|
return preferences.pullFilters[repository];
|
|
}
|
|
|
|
export function setExplorerPreferences(preferences, value) {
|
|
preferences.explorer = normalizeExplorer({
|
|
...preferences.explorer,
|
|
...value,
|
|
});
|
|
return preferences.explorer;
|
|
}
|
|
|
|
export function setPlayerPreferences(preferences, value) {
|
|
preferences.player = normalizePlayer({
|
|
...preferences.player,
|
|
...value,
|
|
});
|
|
return preferences.player;
|
|
}
|