Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0fce6ef03 | |||
| 4223c0c9f5 | |||
| cf6223b512 | |||
| 6bcb712b02 | |||
| 5c7a5cebff | |||
| 86b2f3329a | |||
| 48a044cbd5 | |||
| b695437415 | |||
| c53f596d1d | |||
| 4110eaf45a |
@@ -1,2 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
server/data/
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Clementine
|
||||
|
||||
Clementine is a no-frills, self-hosted plain text editor.
|
||||
It doesn't do fancy Markdown formatting or hyperlinks, though you can certainly still use it to write Markdown!
|
||||
All it does is get out of your way and give you a place to type.
|
||||
If that's all you wanted, Clementine might be for you.
|
||||
|
||||
Users beware: Clementine is currently at a very early stage of development!
|
||||
I can't guarantee it won't eat your work, and it (just barely) meets my own needs.
|
||||
|
||||
## Planned features
|
||||
|
||||
Clementine is already architected to be offline-friendly: all data is in IndexedDB.
|
||||
It's not a proper PWA yet, so you do need an internet connection on mobile in order to initially load the page.
|
||||
But once loaded, no further connection is needed.
|
||||
|
||||
In order to support cross-device sync in the future, all data is represented using CRDTs (specifically, [Yjs](https://yjs.dev/)).
|
||||
When you host Clementine, your server will track the various CRDT deltas in a SQLite database file, and your clients will periodically sync themselves with the contents of that database.
|
||||
The intent is to allow "collaboration with yourself".
|
||||
For example, you ought to be able to go back and forth between writing on your phone and revising on your laptop, without fear of the two devices accidentally overwriting each other's changes.
|
||||
|
||||
The sync system is architected so the server does not need to run Yjs itself; all the server needs to do is pass opaque blobs among the clients.
|
||||
This restriction means the backend can potentially be very lightweight and have implementations in other languages besides JS.
|
||||
It also allows the data to be encrypted client-side, using a key the server doesn't have.
|
||||
(The amount of security this actually adds depends on your threat model. But if you sleep better with a padlock on your diary, this might give you some peace of mind.)
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
|
||||
<title>Story Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
|
||||
<title>Story Editor</title>
|
||||
<title>Clementine</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+758
-375
File diff suppressed because it is too large
Load Diff
+12
-10
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "story-editor",
|
||||
"name": "clementine",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"license": "ISC",
|
||||
@@ -7,26 +7,28 @@
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "concurrently 'vite' 'npm run dev:backend'",
|
||||
"dev:backend": "cd server/node && npm run dev",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/node": "24.3.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.1",
|
||||
"vite-plugin-solid": "^2.10.2"
|
||||
"concurrently": "^9.2.1",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-solid": "^2.11.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"codemirror": "^6.0.2",
|
||||
"idb": "^8.0.3",
|
||||
"lodash": "^4.17.21",
|
||||
"solid-js": "^1.9.2",
|
||||
"solid-js": "^1.9.10",
|
||||
"y-codemirror.next": "^0.3.5",
|
||||
"yjs": "^13.6.27"
|
||||
"yjs": "^13.6.29"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1927
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "clementine-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"express": "^5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^5.0.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDatabase(): Database.Database {
|
||||
if (!db) {
|
||||
db = openDatabase();
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function openDatabase() {
|
||||
const dbPath = path.join(__dirname, '../../data/database.db');
|
||||
|
||||
db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
console.log(`Database connected at ${dbPath}`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function closeDatabase() {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
console.log('Database connection closed');
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', closeDatabase);
|
||||
process.on('SIGTERM', closeDatabase);
|
||||
@@ -0,0 +1,25 @@
|
||||
import express from 'express';
|
||||
import { getDatabase } from './db';
|
||||
|
||||
const db = getDatabase();
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/api/hello', (req, res) => {
|
||||
res.json({ message: 'Hello World!' });
|
||||
});
|
||||
|
||||
app.get('/api/db', (req, res) => {
|
||||
const result = db.prepare('SELECT 1 + 2 AS test').get();
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => process.exit(0));
|
||||
process.on('SIGTERM', () => process.exit(0));
|
||||
@@ -11,6 +11,7 @@ import Editor from './Editor';
|
||||
import { HashRouter, Route, useNavigate } from '@solidjs/router';
|
||||
import { defaultVault } from '../sync';
|
||||
import { getDocsMap } from '../vault';
|
||||
import DebugView from './Debug';
|
||||
|
||||
interface NavbarProps {
|
||||
fileId: Accessor<string | null>;
|
||||
@@ -33,7 +34,7 @@ function Layout(props: any) {
|
||||
const navigate = useNavigate();
|
||||
const [fileId, setFileId] = createSignal<string | null>(null);
|
||||
const [numUpdates, setNumUpdates] = createSignal<number | null>(null);
|
||||
const docsMap = getDocsMap(defaultVault.doc);
|
||||
const docsMap = getDocsMap(defaultVault);
|
||||
|
||||
const title = () => {
|
||||
const id = fileId();
|
||||
@@ -67,6 +68,7 @@ function App() {
|
||||
<HashRouter root={Layout}>
|
||||
<Route path="" component={Chooser} />
|
||||
<Route path="doc/:id" component={Editor} />
|
||||
<Route path="debug" component={DebugView} />
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ function Chooser() {
|
||||
const { setFileId } = useNavbar();
|
||||
onMount(() => setFileId(null));
|
||||
|
||||
const docsMap = getDocsMap(defaultVault.doc);
|
||||
const docsMap = getDocsMap(defaultVault);
|
||||
|
||||
const computeDocsList = () => {
|
||||
const result: DocInfo[] = [];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createSignal, For } from 'solid-js';
|
||||
|
||||
const [getDebugLogs, setDebugLogs] = createSignal<string[]>([]);
|
||||
|
||||
export function debugLog(message: string, ...args: any[]) {
|
||||
console.log(message, ...args);
|
||||
const components = [message, ...args.map((x) => JSON.stringify(x))];
|
||||
const line = components.join(' ');
|
||||
|
||||
const allLines = [...getDebugLogs(), line];
|
||||
if (allLines.length > 1000) {
|
||||
allLines.splice(0, allLines.length - 1000);
|
||||
}
|
||||
setDebugLogs(allLines);
|
||||
}
|
||||
|
||||
export default function DebugView() {
|
||||
return <For each={getDebugLogs()}>{(logLine) => <div>{logLine}</div>}</For>;
|
||||
}
|
||||
@@ -13,13 +13,16 @@ import {
|
||||
onMount,
|
||||
Show,
|
||||
} from 'solid-js';
|
||||
import { LocalDocument } from '../sync';
|
||||
import { useNavbar } from './App';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import _ from 'lodash';
|
||||
import { closeDoc, getDocStats, openDoc } from '../persistence';
|
||||
import { assert } from '../util';
|
||||
|
||||
function Editor() {
|
||||
const { id: fileId } = useParams();
|
||||
assert(fileId, 'Editor component loaded without a file');
|
||||
|
||||
const { setFileId, setNumUpdates } = useNavbar();
|
||||
onMount(() => {
|
||||
setFileId(fileId);
|
||||
@@ -27,25 +30,25 @@ function Editor() {
|
||||
|
||||
let editorDiv;
|
||||
let editorView: EditorView;
|
||||
const [syncedDoc] = createResource(async () => LocalDocument.load(fileId));
|
||||
const [syncedDoc] = createResource(async () => openDoc(fileId));
|
||||
|
||||
createEffect(() => {
|
||||
const doc = syncedDoc();
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
const ydoc = doc.doc;
|
||||
|
||||
const getNumRecords = () => getDocStats(doc).numRecords;
|
||||
const refreshNumUpdates = _.debounce(() => {
|
||||
setNumUpdates(doc.numUpdates);
|
||||
setNumUpdates(getNumRecords());
|
||||
}, 100);
|
||||
setNumUpdates(doc.numUpdates);
|
||||
doc.doc.on('update', () => {
|
||||
setNumUpdates(getNumRecords());
|
||||
doc.on('update', () => {
|
||||
setTimeout(refreshNumUpdates, 1500);
|
||||
});
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: ydoc.getText().toString(),
|
||||
doc: doc.getText().toString(),
|
||||
extensions: [
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of({
|
||||
@@ -56,7 +59,7 @@ function Editor() {
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
scrollPastEnd(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
yCollab(ydoc.getText(), null),
|
||||
yCollab(doc.getText(), null),
|
||||
],
|
||||
});
|
||||
editorView = new EditorView({ state, parent: editorDiv });
|
||||
@@ -65,7 +68,10 @@ function Editor() {
|
||||
onCleanup(() => {
|
||||
setNumUpdates(null);
|
||||
editorView?.destroy();
|
||||
syncedDoc()?.finish();
|
||||
const doc = syncedDoc();
|
||||
if (doc) {
|
||||
closeDoc(doc);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
+37
-35
@@ -2,70 +2,65 @@ import * as Y from 'yjs';
|
||||
import { docsDb, LOCAL_DELTA_STORE } from './sync';
|
||||
import { assert } from './util';
|
||||
import _ from 'lodash';
|
||||
import { debugLog } from './components/Debug';
|
||||
|
||||
const COMPACTION_THRESHOLD = 200;
|
||||
const COMPACTION_THRESHOLD = 50;
|
||||
|
||||
export async function openDoc(id: string): Promise<Y.Doc> {
|
||||
// Return existing doc if we still have it open
|
||||
const previousState = idToState.get(id);
|
||||
if (previousState) {
|
||||
previousState.refCount += 1;
|
||||
return previousState.ydoc;
|
||||
return previousState.savedDoc.ydoc;
|
||||
}
|
||||
|
||||
const state = await initialLoad(id);
|
||||
const state: State = {
|
||||
refCount: 1,
|
||||
savedDoc: new SavedDoc(id),
|
||||
};
|
||||
idToState.set(id, state);
|
||||
docToState.set(state.ydoc, state);
|
||||
docToId.set(state.savedDoc.ydoc, id);
|
||||
await state.savedDoc.connect();
|
||||
debugLog(`Opened doc ${id}`);
|
||||
|
||||
return state.ydoc;
|
||||
return state.savedDoc.ydoc;
|
||||
}
|
||||
|
||||
export function getDocStats(doc: Y.Doc) {
|
||||
const id = docToId.get(doc);
|
||||
assert(id, 'Document is not already open');
|
||||
const state = idToState.get(id)!;
|
||||
return state.savedDoc.getInfo();
|
||||
}
|
||||
|
||||
export function closeDoc(doc: Y.Doc) {
|
||||
const state = docToState.get(doc);
|
||||
assert(state, 'Document is not already open');
|
||||
const id = docToId.get(doc);
|
||||
assert(id, 'Document is not already open');
|
||||
const state = idToState.get(id)!;
|
||||
|
||||
state.refCount -= 1;
|
||||
if (state.refCount <= 0) {
|
||||
idToState.delete(state.id);
|
||||
docToState.delete(doc);
|
||||
doc.destroy();
|
||||
idToState.delete(id);
|
||||
docToId.delete(doc);
|
||||
state.savedDoc.flush().then(() => doc.destroy());
|
||||
}
|
||||
debugLog(`Closed doc ${id} (refcount ${state.refCount})`);
|
||||
}
|
||||
|
||||
const docToId = new Map<Y.Doc, string>();
|
||||
const idToState = new Map<string, State>();
|
||||
const docToState = new Map<Y.Doc, State>();
|
||||
|
||||
type State = {
|
||||
// TODO: Move more of this into class
|
||||
id: string;
|
||||
deltaBuffer: Uint8Array[];
|
||||
numRecords: number;
|
||||
refCount: number;
|
||||
ydoc: Y.Doc;
|
||||
savedDoc: SavedDoc;
|
||||
};
|
||||
|
||||
async function initialLoad(id: string): Promise<State> {
|
||||
const ydoc = new Y.Doc();
|
||||
const records = await docsDb.getAll(
|
||||
LOCAL_DELTA_STORE,
|
||||
IDBKeyRange.bound([id, -Infinity], [id, Infinity])
|
||||
);
|
||||
Y.applyUpdate(ydoc, Y.mergeUpdates(records));
|
||||
return {
|
||||
id,
|
||||
deltaBuffer: [],
|
||||
numRecords: records.length,
|
||||
refCount: 1,
|
||||
ydoc,
|
||||
};
|
||||
}
|
||||
|
||||
class BufferedWriter {
|
||||
class SavedDoc {
|
||||
readonly docId: string;
|
||||
readonly ydoc: Y.Doc;
|
||||
private deltaBuffer: Uint8Array[];
|
||||
private docId: string;
|
||||
private numRecords: number;
|
||||
private debouncedFlush: () => void;
|
||||
private ydoc: Y.Doc;
|
||||
|
||||
constructor(docId: string) {
|
||||
this.deltaBuffer = [];
|
||||
@@ -81,6 +76,10 @@ class BufferedWriter {
|
||||
this.ydoc = new Y.Doc();
|
||||
}
|
||||
|
||||
public getInfo(): { numRecords: number } {
|
||||
return { numRecords: this.numRecords };
|
||||
}
|
||||
|
||||
public async connect(): Promise<Y.Doc> {
|
||||
const records = await docsDb.getAll(
|
||||
LOCAL_DELTA_STORE,
|
||||
@@ -128,6 +127,9 @@ class BufferedWriter {
|
||||
if (this.numRecords < COMPACTION_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
debugLog(
|
||||
`Running compaction on doc ${this.docId}, ${this.numRecords} records...`
|
||||
);
|
||||
const tx = docsDb.transaction(LOCAL_DELTA_STORE, 'readwrite');
|
||||
|
||||
// Make sure we're not missing anything by pulling in all changes.
|
||||
|
||||
+4
-59
@@ -3,6 +3,7 @@ import * as Y from 'yjs';
|
||||
import { openDB, type DBSchema } from 'idb';
|
||||
import { assert, generateId } from './util';
|
||||
import { getDocsMap, getVaultMap, type DocMap } from './vault';
|
||||
import { openDoc } from './persistence';
|
||||
|
||||
const DB_NAME = 'synced-docs';
|
||||
export const LOCAL_DELTA_STORE = 'local-updates';
|
||||
@@ -34,61 +35,6 @@ export const docsDb = await openDB<DocsDatabase>(DB_NAME, 3, {
|
||||
},
|
||||
});
|
||||
|
||||
export class LocalDocument {
|
||||
readonly id: string;
|
||||
readonly doc: Y.Doc;
|
||||
private updates: Uint8Array[];
|
||||
private totalUpdates: number;
|
||||
|
||||
private constructor(id: string, ydoc: Y.Doc) {
|
||||
this.id = id;
|
||||
this.doc = ydoc;
|
||||
this.updates = [];
|
||||
this.totalUpdates = 0;
|
||||
|
||||
const flush = _.debounce(() => this.flush(), 1000);
|
||||
this.doc.on('update', (update: Uint8Array) => {
|
||||
this.updates.push(update);
|
||||
flush();
|
||||
});
|
||||
}
|
||||
|
||||
get numUpdates(): number {
|
||||
return this.totalUpdates;
|
||||
}
|
||||
|
||||
public static async load(id: string): Promise<LocalDocument> {
|
||||
// Build Y.Doc from records on disk
|
||||
const updates: Uint8Array[] = await docsDb.getAll(
|
||||
LOCAL_DELTA_STORE,
|
||||
IDBKeyRange.bound([id, -Infinity], [id, Infinity])
|
||||
);
|
||||
const ydoc = new Y.Doc();
|
||||
Y.applyUpdate(ydoc, Y.mergeUpdates(updates));
|
||||
|
||||
// Build LocalDocument instance
|
||||
const result = new LocalDocument(id, ydoc);
|
||||
result.totalUpdates = updates.length;
|
||||
return result;
|
||||
}
|
||||
|
||||
public async flush(): Promise<void> {
|
||||
const batch = this.updates;
|
||||
this.updates = [];
|
||||
if (batch.length > 0) {
|
||||
const update = Y.mergeUpdates(batch);
|
||||
const key: LocalDeltaKey = [this.id, new Date().valueOf()];
|
||||
await docsDb.add(LOCAL_DELTA_STORE, update, key);
|
||||
this.totalUpdates += 1;
|
||||
}
|
||||
}
|
||||
|
||||
public async finish(): Promise<void> {
|
||||
await this.flush();
|
||||
this.doc.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function getOrCreateVaultDoc() {
|
||||
const records = await docsDb.getAll(VAULT_STORE);
|
||||
if (records.length > 1) {
|
||||
@@ -104,8 +50,8 @@ async function getOrCreateVaultDoc() {
|
||||
if (!id) {
|
||||
throw new Error("Vault record didn't have id");
|
||||
}
|
||||
const vault = await LocalDocument.load(id);
|
||||
const vaultMap = getVaultMap(vault.doc);
|
||||
const vault = await openDoc(id);
|
||||
const vaultMap = getVaultMap(vault);
|
||||
if (!vaultMap.has('id')) {
|
||||
vaultMap.set('id', id);
|
||||
}
|
||||
@@ -125,9 +71,8 @@ async function getOrCreateVaultDoc() {
|
||||
}
|
||||
|
||||
export const defaultVault = await getOrCreateVaultDoc();
|
||||
// console.log(defaultVault.doc.getMap().toJSON())
|
||||
|
||||
const docsMap = getDocsMap(defaultVault.doc);
|
||||
const docsMap = getDocsMap(defaultVault);
|
||||
if (docsMap.size === 0) {
|
||||
console.log('Running vault migration...');
|
||||
for (const oldKey of ['foo', 'bar', 'baz']) {
|
||||
|
||||
+70
-3
@@ -1,7 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, Plugin } from 'vite';
|
||||
import solidPlugin from 'vite-plugin-solid';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -13,12 +15,77 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'index.html'),
|
||||
app: resolve(__dirname, 'app.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
},
|
||||
plugins: [solidPlugin()],
|
||||
},
|
||||
plugins: [solidPlugin(), versionPlugin()],
|
||||
});
|
||||
|
||||
function versionPlugin(): Plugin {
|
||||
return {
|
||||
name: 'generate-version',
|
||||
apply: 'build',
|
||||
async generateBundle() {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'version.txt',
|
||||
source: await getVersionInfo(),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getVersionInfo(): Promise<string> {
|
||||
const info: Record<string, string> = {
|
||||
build: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const fromJujutsu = async () => {
|
||||
const template = [
|
||||
'change_id.short()',
|
||||
'commit_id.short()',
|
||||
'committer.timestamp().format("%Y-%m-%d")',
|
||||
'description.first_line()',
|
||||
].join(' ++ "\\t" ++ ');
|
||||
const { stdout } = await execAsync(
|
||||
`jj log -r '::@ & ~description("")' -n1 --no-graph -T '${template}'`
|
||||
);
|
||||
const [change, hash, date, msg] = stdout.trim().split('\t');
|
||||
info.change = change;
|
||||
info.hash = hash;
|
||||
info.commit = `${msg} (${date})`;
|
||||
};
|
||||
|
||||
const fromGit = async () => {
|
||||
const { stdout } = await execAsync(
|
||||
'git log --format="%h%x09%cs%x09%s" -n 1'
|
||||
);
|
||||
const [hash, date, msg] = stdout.trim().split('\t');
|
||||
info.hash = hash;
|
||||
info.commit = `${msg} (${date})`;
|
||||
};
|
||||
|
||||
try {
|
||||
await fromJujutsu();
|
||||
} catch {
|
||||
try {
|
||||
await fromGit();
|
||||
} catch {
|
||||
// No VCS available
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
Object.entries(info)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n') + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user