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
4 changes: 3 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
"enabled": true,
"rules": {
"style": {
"useNodejsImportProtocol": "off"
"useImportType": "off",
"useNodejsImportProtocol": "off",
"noUnusedTemplateLiteral": "off"
},
"recommended": true
}
Expand Down
8,122 changes: 4,399 additions & 3,723 deletions package-lock.json

Large diffs are not rendered by default.

13 changes: 4 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@
"name": "git-remote-copy",
"version": "0.0.0-development",
"description": "A utility command to copy any folders or files of any public github/gitlab/bitbucket repo to selected path, without copying the whole repo",
"files": [
"!lib/__tests__/**/*",
"lib/**/*",
"bin/**/*"
],
"files": ["!lib/__tests__/**/*", "lib/**/*", "bin/**/*"],
"bin": {
"git-remote-copy": "./bin/index.js"
},
Expand Down Expand Up @@ -54,6 +50,7 @@
"dependencies": {
"commander": "^7.2.0",
"node-fetch": "^3.3.2",
"sqlite3": "^5.1.7",
"tar": "^7.4.3",
"tar-stream": "^3.1.7"
},
Expand All @@ -64,7 +61,7 @@
"@types/mock-fs": "^4.13.4",
"@types/node": "^12.20.11",
"@types/tar-stream": "^3.1.3",
"chalk": "^4.1.1",
"chalk": "^4.1.2",
"conventional-changelog-conventionalcommits": "^5.0.0",
"execa": "^5.1.1",
"husky": "^6.0.0",
Expand All @@ -79,9 +76,7 @@
"*.ts": "npx @biomejs/biome format --write ./src"
},
"release": {
"branches": [
"main"
],
"branches": ["main"],
"plugins": [
[
"@semantic-release/commit-analyzer",
Expand Down
8 changes: 6 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import Main from "./modules/Main";
import { HistoryCommand } from "./modules/commands/HistoryCommand";
import { MainCopyCommand } from "./modules/commands/MainCopyCommand";

const commands = [new MainCopyCommand()];
const historyCommand = new HistoryCommand();
const mainCopyCommand = new MainCopyCommand();

const argParser = new Main(commands);
const commands = [mainCopyCommand, historyCommand];

new Main(commands);
180 changes: 180 additions & 0 deletions src/modules/commands/HistoryCommand/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import readline from "readline";
import chalk from "chalk";
import { HistoryRecord, TCustomEvents } from "../../../types";
import events from "../../events";
import HistoryRepository from "./historyRepository";

class HistoryCommand {
private records: HistoryRecord[] = [];
private currentIndex = 0;
private isDetailedView = false;
private selectedIndex = -1;
private terminalHeight = 0;

constructor() {
this.listenForNewHistoryItem();
}

async execute() {
this.records = await HistoryRepository.getInstance().getAllRecords();
if (this.records.length === 0) {
console.log("🙅 Copying history empty");
return;
}
this.currentIndex = this.records.length - 1;
this.displayList();
this.listenForNavigation();
}

private displayList() {
// Get terminal height and reserve space for header and footer
this.terminalHeight = process.stdout.rows - 9;

console.clear();
console.log("📋 Copy History");

// Calculate visible range
let startIndex = Math.max(
0,
this.currentIndex - Math.ceil(this.terminalHeight / 2),
);
let endIndex = Math.min(
this.records.length,
startIndex + this.terminalHeight,
);

// If we're near the start, show from the beginning
if (this.currentIndex < Math.ceil(this.terminalHeight / 2)) {
startIndex = 0;
endIndex = Math.min(this.records.length, this.terminalHeight);
}

// Show scroll indicators if there are more items
if (startIndex > 0) {
console.log(
chalk.bgBlue.white(` ↑ More items above (${startIndex} items) `),
);
} else {
console.log("");
}

// Display visible portion of the list
for (let i = startIndex; i < endIndex; i++) {
const record = this.records[i];
const isSelected = i === this.currentIndex;
const isHighlighted = i === this.selectedIndex;

const line = `Copy #${i + 1} - ${record.source} → ${record.destination}`;

if (isSelected) {
console.log(chalk.bgYellow.black(line));
} else if (isHighlighted) {
console.log(chalk.yellow(line));
} else {
console.log(line);
}
}
if (endIndex < this.records.length) {
const remainingItems = this.records.length - endIndex;
console.log(
chalk.bgBlue.white(` ↓ More items below (${remainingItems} items) `),
);
} else {
console.log("");
}

console.log("\nNavigation:");
console.log("j/k - Move up/down");
console.log("Enter - Toggle detailed view");
console.log("q - Back/Exit");
}

private displayDetailedView() {
const record = this.records[this.selectedIndex];
console.clear();
console.log("📄 Copy Details");
console.log("───────────────");
console.log(`Copy #${this.selectedIndex + 1} of ${this.records.length}`);
console.log(`From: ${record.source}`);
console.log(`To: ${record.destination}`);
console.log(`When: ${record.timestamp}`);
console.log(`Size: ${record.size} bytes`);
console.log("\nPress Enter to return to list");
console.log("Press q to exit");
}

private listenForNavigation() {
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) process.stdin.setRawMode(true);

const onKeyPress = (_: string, key: readline.Key) => {
if (key.ctrl && key.name === "c") return this.exit();

switch (key.name) {
case "j":
this.navigateDown();
break;
case "k":
this.navigateUp();
break;
case "return":
case "enter":
this.toggleView();
break;
case "q":
this.quitOrBack();
break;
}
};

process.stdin.on("keypress", onKeyPress);
}

private navigateDown() {
if (this.currentIndex < this.records.length - 1) {
this.currentIndex++;
this.displayList();
}
}

private navigateUp() {
if (this.currentIndex > 0) {
this.currentIndex--;
this.displayList();
}
}

private toggleView() {
if (this.isDetailedView) {
this.isDetailedView = false;
this.displayList();
} else {
this.isDetailedView = true;
this.selectedIndex = this.currentIndex;
this.displayDetailedView();
}
}

private quitOrBack() {
if (this.isDetailedView) {
this.isDetailedView = false;
this.displayList();
} else {
this.exit();
}
}

private exit() {
if (process.stdin.isTTY) process.stdin.setRawMode(false);
process.stdin.removeAllListeners("keypress");
process.exit(0);
}

private listenForNewHistoryItem() {
events.on(TCustomEvents.NEW_HISTORY_ITEM, (newRecord: HistoryRecord) => {
HistoryRepository.getInstance().addRecord(newRecord);
});
}
}

export default HistoryCommand;
59 changes: 59 additions & 0 deletions src/modules/commands/HistoryCommand/historyRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { EventEmitter } from "events";
import path from "path";
import sqlite3 from "sqlite3";
import { type HistoryRecord } from "../../../types";

class HistoryRepository {
private static instance: HistoryRepository;
private db: sqlite3.Database;

private constructor() {
const dbPath = path.resolve(__dirname, "../../history.sqlite");
this.db = new sqlite3.Database(dbPath);
this.db.serialize(() => {
this.db.run(`CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
destination TEXT NOT NULL,
timestamp TEXT NOT NULL,
size INTEGER NOT NULL
)`);
});
}

public static getInstance(): HistoryRepository {
if (!HistoryRepository.instance) {
HistoryRepository.instance = new HistoryRepository();
}
return HistoryRepository.instance;
}

public addRecord(record: HistoryRecord): Promise<void> {
Comment thread
mari4kaa marked this conversation as resolved.
return new Promise((resolve, reject) => {
this.db.run(
`INSERT INTO history (source, destination, timestamp, size) VALUES (?, ?, ?, ?)`,
[record.source, record.destination, record.timestamp, record.size],
(err: any) => {
if (err) reject(err);
else resolve();
},
);
});
}

public getAllRecords(): Promise<HistoryRecord[]> {
return new Promise((resolve, reject) => {
console.log("Fetching all history records...");
this.db.all(
`SELECT * FROM history ORDER BY timestamp`,
[],
(err: any, rows: HistoryRecord[] | PromiseLike<HistoryRecord[]>) => {
if (err) reject(err);
else resolve(rows);
},
);
});
}
}

export default HistoryRepository;
18 changes: 18 additions & 0 deletions src/modules/commands/HistoryCommand/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Command } from "commander";
import type { CommandWrapper, TCommand } from "../../../types";
import HistoryCommandLogic from "./history";

export class HistoryCommand implements CommandWrapper {
command: TCommand;

constructor() {
const logic = new HistoryCommandLogic();

this.command = new Command()
.name("history")
.description("Show copy history")
.action(async () => {
await logic.execute();
});
}
}
17 changes: 12 additions & 5 deletions src/modules/commands/MainCopyCommand/GithubCopySourceStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ class GithubCopySourceStrategy implements TCopySourceStrategy {
destination,
} satisfies GithubApiHelperObject;

await this.downloadTarball(helperObject);
return await this.downloadTarball(helperObject);
}

async downloadTarball(config: GithubApiHelperObject) {
private async downloadTarball(config: GithubApiHelperObject) {
let totalSize = 0;
const targetedPath = config.path.join("/");
const targetFolderName = targetedPath.split("/").pop() || "";

Expand Down Expand Up @@ -91,6 +92,10 @@ class GithubCopySourceStrategy implements TCopySourceStrategy {
: targetFolderName;

if (header.type === "file") {
if (header.size) {
totalSize += header.size;
}

const fullPath = path.join(config.destination, destinationPath);

const dir = path.dirname(fullPath);
Expand Down Expand Up @@ -143,24 +148,26 @@ class GithubCopySourceStrategy implements TCopySourceStrategy {
nodeStream.pipe(gunzip).pipe(extract);

// Return a promise that resolves when extraction is complete
return new Promise<void>((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
extract.on("finish", resolve);
extract.on("error", reject);
nodeStream.on("error", reject);
gunzip.on("error", reject);
});

return totalSize;
} catch (error) {
console.error("Download tarball failed:", error);
throw error;
}
}

toNodeReadable(webStream: ReadableStream<Uint8Array>): Readable {
private toNodeReadable(webStream: ReadableStream<Uint8Array>): Readable {
// @ts-ignore
return Readable?.fromWeb(webStream as never);
}

async getGhRepoDefaultBranch(repo: string, owner: string) {
private async getGhRepoDefaultBranch(repo: string, owner: string) {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
);
Expand Down
Loading