gitmaps 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- package/app/api/auth/favorites/route.ts +56 -0
- package/app/api/auth/github/callback/route.ts +103 -0
- package/app/api/auth/github/route.ts +32 -0
- package/app/api/auth/me/route.ts +52 -0
- package/app/api/auth/positions/route.ts +50 -0
- package/app/api/chat/route.ts +101 -0
- package/app/api/connections/route.ts +72 -0
- package/app/api/github/repos/route.ts +111 -0
- package/app/api/positions/route.ts +80 -0
- package/app/api/repo/branch-diff/route.ts +201 -0
- package/app/api/repo/branches/route.ts +53 -0
- package/app/api/repo/browse/route.ts +55 -0
- package/app/api/repo/clone/route.ts +78 -0
- package/app/api/repo/clone-stream/route.ts +131 -0
- package/app/api/repo/file-content/route.ts +28 -0
- package/app/api/repo/file-delete/route.ts +62 -0
- package/app/api/repo/file-history/route.ts +45 -0
- package/app/api/repo/file-rename/route.ts +83 -0
- package/app/api/repo/file-save/route.ts +45 -0
- package/app/api/repo/files/route.ts +169 -0
- package/app/api/repo/git-blame/route.ts +86 -0
- package/app/api/repo/git-commit/route.ts +40 -0
- package/app/api/repo/git-heatmap/route.ts +55 -0
- package/app/api/repo/imports/route.ts +154 -0
- package/app/api/repo/load/route.ts +56 -0
- package/app/api/repo/mode/route.ts +14 -0
- package/app/api/repo/search/route.ts +127 -0
- package/app/api/repo/tree/route.ts +104 -0
- package/app/api/repo/upload/route.ts +53 -0
- package/app/api/repo/validate-path.ts +53 -0
- package/app/canvas_users.db +0 -0
- package/app/canvas_users.db-shm +0 -0
- package/app/canvas_users.db-wal +0 -0
- package/app/globals.css +7899 -0
- package/app/layout.tsx +493 -0
- package/app/lib/auth.ts +193 -0
- package/app/lib/auto-save.ts +137 -0
- package/app/lib/branch-compare.ts +443 -0
- package/app/lib/breadcrumbs.ts +170 -0
- package/app/lib/canvas-export.ts +358 -0
- package/app/lib/canvas-text.ts +912 -0
- package/app/lib/canvas.ts +564 -0
- package/app/lib/card-arrangement.ts +188 -0
- package/app/lib/card-context-menu.tsx +453 -0
- package/app/lib/card-diff-markers.ts +270 -0
- package/app/lib/card-expand.ts +189 -0
- package/app/lib/card-groups.ts +246 -0
- package/app/lib/cards.tsx +914 -0
- package/app/lib/chat.tsx +308 -0
- package/app/lib/code-editor.ts +508 -0
- package/app/lib/command-palette.ts +262 -0
- package/app/lib/connections.tsx +1037 -0
- package/app/lib/context.ts +94 -0
- package/app/lib/cursor-sharing.ts +281 -0
- package/app/lib/dependency-graph.ts +438 -0
- package/app/lib/events.tsx +1747 -0
- package/app/lib/file-card-plugin.ts +134 -0
- package/app/lib/file-modal.tsx +849 -0
- package/app/lib/file-preview.ts +400 -0
- package/app/lib/file-tabs.ts +318 -0
- package/app/lib/galaxydraw-bridge.ts +477 -0
- package/app/lib/galaxydraw.test.ts +229 -0
- package/app/lib/global-search.ts +264 -0
- package/app/lib/goto-definition.ts +224 -0
- package/app/lib/heatmap.ts +178 -0
- package/app/lib/hidden-files.tsx +222 -0
- package/app/lib/layers.ts +0 -0
- package/app/lib/layers.tsx +365 -0
- package/app/lib/loading.tsx +45 -0
- package/app/lib/multi-repo.ts +286 -0
- package/app/lib/new-file-dialog.tsx +230 -0
- package/app/lib/onboarding.tsx +213 -0
- package/app/lib/perf-overlay.ts +360 -0
- package/app/lib/positions.ts +176 -0
- package/app/lib/pr-review.ts +374 -0
- package/app/lib/production-mode.ts +47 -0
- package/app/lib/repo.tsx +977 -0
- package/app/lib/settings-modal.tsx +374 -0
- package/app/lib/settings.ts +97 -0
- package/app/lib/shortcuts-panel.ts +141 -0
- package/app/lib/status-bar.ts +128 -0
- package/app/lib/symbol-outline.ts +212 -0
- package/app/lib/syntax.ts +177 -0
- package/app/lib/tab-diff.ts +238 -0
- package/app/lib/user.tsx +133 -0
- package/app/lib/utils.ts +78 -0
- package/app/lib/viewport-culling.ts +728 -0
- package/app/page.client.tsx +215 -0
- package/app/page.tsx +291 -0
- package/app/state/machine.js +196 -0
- package/app/styles/main.css +2168 -0
- package/banner.png +0 -0
- package/cli.ts +44 -0
- package/package.json +75 -0
- package/packages/galaxydraw/README.md +296 -0
- package/packages/galaxydraw/banner.png +0 -0
- package/packages/galaxydraw/demo/build-static.ts +100 -0
- package/packages/galaxydraw/demo/client.ts +154 -0
- package/packages/galaxydraw/demo/dist/client.js +8 -0
- package/packages/galaxydraw/demo/index.html +256 -0
- package/packages/galaxydraw/demo/server.ts +96 -0
- package/packages/galaxydraw/dist/index.js +984 -0
- package/packages/galaxydraw/dist/index.js.map +16 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
- package/packages/galaxydraw/package.json +49 -0
- package/packages/galaxydraw/perf.test.ts +284 -0
- package/packages/galaxydraw/src/core/cards.ts +435 -0
- package/packages/galaxydraw/src/core/engine.ts +339 -0
- package/packages/galaxydraw/src/core/events.ts +81 -0
- package/packages/galaxydraw/src/core/layout.ts +136 -0
- package/packages/galaxydraw/src/core/minimap.ts +216 -0
- package/packages/galaxydraw/src/core/state.ts +177 -0
- package/packages/galaxydraw/src/core/viewport.ts +106 -0
- package/packages/galaxydraw/src/galaxydraw.css +166 -0
- package/packages/galaxydraw/src/index.ts +40 -0
- package/packages/galaxydraw/tsconfig.json +30 -0
- package/server.ts +62 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Card arrangement — row, column, grid layout for selected cards.
|
|
4
|
+
* Extracted from cards.tsx for modularity.
|
|
5
|
+
*
|
|
6
|
+
* Works with both materialized DOM cards and deferred/pill cards
|
|
7
|
+
* so arrange functions work at any zoom level.
|
|
8
|
+
*/
|
|
9
|
+
import { measure } from 'measure-fn';
|
|
10
|
+
import type { CanvasContext } from './context';
|
|
11
|
+
import { savePosition } from './positions';
|
|
12
|
+
import { updateMinimap } from './canvas';
|
|
13
|
+
import { renderConnections } from './connections';
|
|
14
|
+
|
|
15
|
+
// ─── Helpers ────────────────────────────────────────────
|
|
16
|
+
interface CardInfo {
|
|
17
|
+
path: string;
|
|
18
|
+
card: HTMLElement | null; // null for deferred-only cards
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
w: number;
|
|
22
|
+
h: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getSelectedCardsInfo(ctx: CanvasContext): CardInfo[] {
|
|
26
|
+
const selected = ctx.snap().context.selectedCards;
|
|
27
|
+
const infos: CardInfo[] = [];
|
|
28
|
+
const seen = new Set<string>();
|
|
29
|
+
|
|
30
|
+
// 1. Check materialized DOM cards (ctx.fileCards)
|
|
31
|
+
selected.forEach(path => {
|
|
32
|
+
const card = ctx.fileCards.get(path);
|
|
33
|
+
if (card) {
|
|
34
|
+
seen.add(path);
|
|
35
|
+
const x = parseFloat(card.style.left);
|
|
36
|
+
const y = parseFloat(card.style.top);
|
|
37
|
+
if (isNaN(x) || isNaN(y)) return;
|
|
38
|
+
infos.push({
|
|
39
|
+
path, card,
|
|
40
|
+
x, y,
|
|
41
|
+
w: card.offsetWidth || 580,
|
|
42
|
+
h: card.offsetHeight || 400,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// 2. Check deferred cards (not materialized yet — in pill/zoomed-out mode)
|
|
48
|
+
if (ctx.deferredCards) {
|
|
49
|
+
selected.forEach(path => {
|
|
50
|
+
if (seen.has(path)) return;
|
|
51
|
+
const entry = ctx.deferredCards.get(path);
|
|
52
|
+
if (entry) {
|
|
53
|
+
seen.add(path);
|
|
54
|
+
const x = entry.x;
|
|
55
|
+
const y = entry.y;
|
|
56
|
+
if (isNaN(x) || isNaN(y)) return;
|
|
57
|
+
infos.push({
|
|
58
|
+
path,
|
|
59
|
+
card: null, // no DOM card — will need to update pill + deferred entry
|
|
60
|
+
x, y,
|
|
61
|
+
w: entry.size?.width || 580,
|
|
62
|
+
h: entry.size?.height || 400,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 3. Check pill elements as last fallback
|
|
69
|
+
if (infos.length < selected.length) {
|
|
70
|
+
selected.forEach(path => {
|
|
71
|
+
if (seen.has(path)) return;
|
|
72
|
+
const pill = document.querySelector(`.file-card-pill[data-path="${CSS.escape(path)}"]`) as HTMLElement;
|
|
73
|
+
if (pill) {
|
|
74
|
+
const x = parseFloat(pill.style.left);
|
|
75
|
+
const y = parseFloat(pill.style.top);
|
|
76
|
+
if (isNaN(x) || isNaN(y)) return;
|
|
77
|
+
infos.push({
|
|
78
|
+
path, card: null,
|
|
79
|
+
x, y,
|
|
80
|
+
w: pill.offsetWidth || 580,
|
|
81
|
+
h: pill.offsetHeight || 400,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
infos.sort((a, b) => a.x - b.x || a.y - b.y);
|
|
88
|
+
return infos;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Apply new position to card, deferred entry, and pill */
|
|
92
|
+
function applyPosition(ctx: CanvasContext, info: CardInfo, newX: number, newY: number) {
|
|
93
|
+
// Update DOM card if it exists
|
|
94
|
+
if (info.card) {
|
|
95
|
+
info.card.style.left = `${newX}px`;
|
|
96
|
+
info.card.style.top = `${newY}px`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Update deferred entry
|
|
100
|
+
const deferred = ctx.deferredCards?.get(info.path);
|
|
101
|
+
if (deferred) {
|
|
102
|
+
deferred.x = newX;
|
|
103
|
+
deferred.y = newY;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Update pill element
|
|
107
|
+
const pill = document.querySelector(`.file-card-pill[data-path="${CSS.escape(info.path)}"]`) as HTMLElement;
|
|
108
|
+
if (pill) {
|
|
109
|
+
pill.style.left = `${newX}px`;
|
|
110
|
+
pill.style.top = `${newY}px`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Arrange in a horizontal row ────────────────────────
|
|
115
|
+
export function arrangeRow(ctx: CanvasContext) {
|
|
116
|
+
measure('arrange:row', () => {
|
|
117
|
+
const infos = getSelectedCardsInfo(ctx);
|
|
118
|
+
if (infos.length < 2) return;
|
|
119
|
+
const startX = Math.min(...infos.map(i => i.x));
|
|
120
|
+
const startY = Math.min(...infos.map(i => i.y));
|
|
121
|
+
const gap = 40;
|
|
122
|
+
let curX = startX;
|
|
123
|
+
const commitHash = ctx.snap().context.currentCommitHash || 'allfiles';
|
|
124
|
+
infos.forEach(info => {
|
|
125
|
+
applyPosition(ctx, info, curX, startY);
|
|
126
|
+
savePosition(ctx, commitHash, info.path, curX, startY);
|
|
127
|
+
curX += info.w + gap;
|
|
128
|
+
});
|
|
129
|
+
renderConnections(ctx);
|
|
130
|
+
updateMinimap(ctx);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Arrange in a vertical column ───────────────────────
|
|
135
|
+
export function arrangeColumn(ctx: CanvasContext) {
|
|
136
|
+
measure('arrange:column', () => {
|
|
137
|
+
const infos = getSelectedCardsInfo(ctx);
|
|
138
|
+
if (infos.length < 2) return;
|
|
139
|
+
const startX = Math.min(...infos.map(i => i.x));
|
|
140
|
+
const startY = Math.min(...infos.map(i => i.y));
|
|
141
|
+
const gap = 40;
|
|
142
|
+
let curY = startY;
|
|
143
|
+
const commitHash = ctx.snap().context.currentCommitHash || 'allfiles';
|
|
144
|
+
infos.forEach(info => {
|
|
145
|
+
applyPosition(ctx, info, startX, curY);
|
|
146
|
+
savePosition(ctx, commitHash, info.path, startX, curY);
|
|
147
|
+
curY += info.h + gap;
|
|
148
|
+
});
|
|
149
|
+
renderConnections(ctx);
|
|
150
|
+
updateMinimap(ctx);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Arrange in a grid ──────────────────────────────────
|
|
155
|
+
export function arrangeGrid(ctx: CanvasContext) {
|
|
156
|
+
measure('arrange:grid', () => {
|
|
157
|
+
const infos = getSelectedCardsInfo(ctx);
|
|
158
|
+
if (infos.length < 2) return;
|
|
159
|
+
const cols = Math.ceil(Math.sqrt(infos.length));
|
|
160
|
+
const startX = Math.min(...infos.map(i => i.x));
|
|
161
|
+
const startY = Math.min(...infos.map(i => i.y));
|
|
162
|
+
const gapX = 40, gapY = 40;
|
|
163
|
+
|
|
164
|
+
const colWidths: number[] = [];
|
|
165
|
+
const rowHeights: number[] = [];
|
|
166
|
+
infos.forEach((info, i) => {
|
|
167
|
+
const col = i % cols;
|
|
168
|
+
const row = Math.floor(i / cols);
|
|
169
|
+
colWidths[col] = Math.max(colWidths[col] || 0, info.w);
|
|
170
|
+
rowHeights[row] = Math.max(rowHeights[row] || 0, info.h);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const commitHash = ctx.snap().context.currentCommitHash || 'allfiles';
|
|
174
|
+
infos.forEach((info, i) => {
|
|
175
|
+
const col = i % cols;
|
|
176
|
+
const row = Math.floor(i / cols);
|
|
177
|
+
let x = startX;
|
|
178
|
+
for (let c = 0; c < col; c++) x += (colWidths[c] || 580) + gapX;
|
|
179
|
+
let y = startY;
|
|
180
|
+
for (let r = 0; r < row; r++) y += (rowHeights[r] || 400) + gapY;
|
|
181
|
+
applyPosition(ctx, info, x, y);
|
|
182
|
+
savePosition(ctx, commitHash, info.path, x, y);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
renderConnections(ctx);
|
|
186
|
+
updateMinimap(ctx);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Card context menu — right-click menu for file cards.
|
|
4
|
+
* Extracted from cards.tsx for modularity.
|
|
5
|
+
*/
|
|
6
|
+
import { render } from 'melina/client';
|
|
7
|
+
import type { CanvasContext } from './context';
|
|
8
|
+
import { showToast } from './utils';
|
|
9
|
+
import { hideSelectedFiles } from './hidden-files';
|
|
10
|
+
import { layerState, createLayer, moveFileToLayer, addFileToLayer, removeFileFromLayer, getActiveLayer } from './layers';
|
|
11
|
+
import { isPinned, togglePinCard } from './viewport-culling';
|
|
12
|
+
|
|
13
|
+
// These are imported lazily to avoid circular deps
|
|
14
|
+
let _updateSelectionHighlights: any;
|
|
15
|
+
let _updateArrangeToolbar: any;
|
|
16
|
+
let _openFileModal: any;
|
|
17
|
+
let _toggleCardExpand: any;
|
|
18
|
+
let _fitScreenSize: any;
|
|
19
|
+
|
|
20
|
+
function lazyLoad() {
|
|
21
|
+
if (!_updateSelectionHighlights) {
|
|
22
|
+
const cards = require('./cards');
|
|
23
|
+
_updateSelectionHighlights = cards.updateSelectionHighlights;
|
|
24
|
+
_updateArrangeToolbar = cards.updateArrangeToolbar;
|
|
25
|
+
_openFileModal = cards.openFileModal;
|
|
26
|
+
_toggleCardExpand = cards.toggleCardExpand;
|
|
27
|
+
_fitScreenSize = cards.fitScreenSize;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Context Menu JSX component ─────────────────────
|
|
32
|
+
function ContextMenu({ onAction, onActionLayer, onSelectFolder, isInActiveLayer, pinned, filePath }: {
|
|
33
|
+
onAction: (action: string) => void;
|
|
34
|
+
onActionLayer: (layerId: string) => void;
|
|
35
|
+
onSelectFolder: (dir: string) => void;
|
|
36
|
+
isInActiveLayer: boolean;
|
|
37
|
+
pinned: boolean;
|
|
38
|
+
filePath: string;
|
|
39
|
+
}) {
|
|
40
|
+
const customLayers = layerState.layers.filter(l => l.id !== 'default');
|
|
41
|
+
|
|
42
|
+
// Build ancestor directory chain: app/lib/utils/foo.ts → ['app/lib/utils', 'app/lib', 'app']
|
|
43
|
+
const parts = filePath.split('/');
|
|
44
|
+
const ancestors: string[] = [];
|
|
45
|
+
if (parts.length > 1) {
|
|
46
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
47
|
+
ancestors.push(parts.slice(0, i + 1).join('/'));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<button className="ctx-item" onClick={() => onAction('copy-path')}>📋 Copy path</button>
|
|
54
|
+
<button className="ctx-item" onClick={() => onAction('select')}>☑️ Select</button>
|
|
55
|
+
{ancestors.length > 0 ? (
|
|
56
|
+
<div className="ctx-item ctx-dropdown">
|
|
57
|
+
<span>📁 Select from folder ▸</span>
|
|
58
|
+
<div className="ctx-dropdown-content">
|
|
59
|
+
{ancestors.map(dir => (
|
|
60
|
+
<button key={dir} className="ctx-item" onClick={() => onSelectFolder(dir)}>
|
|
61
|
+
📂 {dir}
|
|
62
|
+
</button>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
) : (
|
|
67
|
+
<button className="ctx-item" onClick={() => onSelectFolder('')}>📁 Select all (root)</button>
|
|
68
|
+
)}
|
|
69
|
+
<button className="ctx-item" onClick={() => onAction('pin')}>{pinned ? '📌 Unpin card' : '📌 Pin card'}</button>
|
|
70
|
+
<div className="ctx-divider"></div>
|
|
71
|
+
<button className="ctx-item" onClick={() => onAction('expand')}>📖 Open in Editor</button>
|
|
72
|
+
<button className="ctx-item" onClick={() => onAction('edit')}>✏️ Edit file</button>
|
|
73
|
+
<button className="ctx-item" onClick={() => onAction('blame')}>👤 Git blame</button>
|
|
74
|
+
<button className="ctx-item" onClick={() => onAction('connect')}>🔗 Connect to...</button>
|
|
75
|
+
<button className="ctx-item" onClick={() => onAction('fit-content')}>📏 Fit content</button>
|
|
76
|
+
<button className="ctx-item" onClick={() => onAction('fit-screen')}>📺 Fit screen</button>
|
|
77
|
+
<div className="ctx-divider"></div>
|
|
78
|
+
<button className="ctx-item" onClick={() => onAction('history')}>🕰️ File history</button>
|
|
79
|
+
<div className="ctx-item ctx-dropdown">
|
|
80
|
+
<span>📦 Move to Layer ▸</span>
|
|
81
|
+
<div className="ctx-dropdown-content">
|
|
82
|
+
{customLayers.length === 0 ? (
|
|
83
|
+
<div className="ctx-item" style="opacity: 0.5; pointer-events: none">No custom layers</div>
|
|
84
|
+
) : (
|
|
85
|
+
customLayers.map(l => (
|
|
86
|
+
<button key={l.id} className="ctx-item" onClick={() => onActionLayer(l.id)}>
|
|
87
|
+
+ {l.name}
|
|
88
|
+
</button>
|
|
89
|
+
))
|
|
90
|
+
)}
|
|
91
|
+
<div className="ctx-divider"></div>
|
|
92
|
+
<button className="ctx-item" onClick={() => onActionLayer('new')}>✨ Create New Layer</button>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
{isInActiveLayer && (
|
|
96
|
+
<button className="ctx-item" onClick={() => onAction('remove-from-layer')} style="color: #ef4444">
|
|
97
|
+
✕ Remove from Layer
|
|
98
|
+
</button>
|
|
99
|
+
)}
|
|
100
|
+
<div className="ctx-divider"></div>
|
|
101
|
+
<button className="ctx-item" onClick={() => onAction('hide')} style="color: #f59e0b">🙈 Hide file</button>
|
|
102
|
+
<button className="ctx-item" onClick={() => onAction('rename')}>✏️ Rename / Move</button>
|
|
103
|
+
<button className="ctx-item" onClick={() => onAction('delete')} style="color: #ef4444">🗑️ Delete file</button>
|
|
104
|
+
</>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Show context menu ──────────────────────────────
|
|
109
|
+
export function showCardContextMenu(ctx: CanvasContext, card: HTMLElement, x: number, y: number) {
|
|
110
|
+
lazyLoad();
|
|
111
|
+
document.querySelector('.card-context-menu')?.remove();
|
|
112
|
+
|
|
113
|
+
const filePath = card.dataset.path;
|
|
114
|
+
const menu = document.createElement('div');
|
|
115
|
+
menu.className = 'card-context-menu';
|
|
116
|
+
menu.style.left = `${x}px`;
|
|
117
|
+
menu.style.top = `${y}px`;
|
|
118
|
+
|
|
119
|
+
// Check if file is in the active layer
|
|
120
|
+
const activeLayer = getActiveLayer();
|
|
121
|
+
const isInActiveLayer = !!(activeLayer && activeLayer.files[filePath]);
|
|
122
|
+
|
|
123
|
+
function handleAction(action: string) {
|
|
124
|
+
menu.remove();
|
|
125
|
+
if (action === 'copy-path') {
|
|
126
|
+
navigator.clipboard.writeText(filePath).then(() => {
|
|
127
|
+
showToast(`Copied: ${filePath}`, 'info');
|
|
128
|
+
});
|
|
129
|
+
} else if (action === 'select') {
|
|
130
|
+
ctx.actor.send({ type: 'SELECT_CARD', path: filePath, shift: false });
|
|
131
|
+
_updateSelectionHighlights(ctx);
|
|
132
|
+
_updateArrangeToolbar(ctx);
|
|
133
|
+
} else if (action === 'hide') {
|
|
134
|
+
hideSelectedFiles(ctx, [filePath]);
|
|
135
|
+
} else if (action === 'remove-from-layer') {
|
|
136
|
+
removeFileFromLayer(ctx, layerState.activeLayerId, filePath);
|
|
137
|
+
} else if (action === 'expand') {
|
|
138
|
+
const state = ctx.snap().context;
|
|
139
|
+
const file = state.commitFiles?.find(f => f.path === filePath) ||
|
|
140
|
+
ctx.allFilesData?.find(f => f.path === filePath) ||
|
|
141
|
+
{ path: filePath, name: filePath.split('/').pop(), lines: 0 };
|
|
142
|
+
_openFileModal(ctx, file);
|
|
143
|
+
} else if (action === 'edit') {
|
|
144
|
+
const state = ctx.snap().context;
|
|
145
|
+
const file = state.commitFiles?.find(f => f.path === filePath) ||
|
|
146
|
+
ctx.allFilesData?.find(f => f.path === filePath) ||
|
|
147
|
+
{ path: filePath, name: filePath.split('/').pop(), lines: 0 };
|
|
148
|
+
_openFileModal(ctx, file, 'edit');
|
|
149
|
+
} else if (action === 'blame') {
|
|
150
|
+
const state = ctx.snap().context;
|
|
151
|
+
const file = state.commitFiles?.find(f => f.path === filePath) ||
|
|
152
|
+
ctx.allFilesData?.find(f => f.path === filePath) ||
|
|
153
|
+
{ path: filePath, name: filePath.split('/').pop(), lines: 0 };
|
|
154
|
+
_openFileModal(ctx, file, 'blame');
|
|
155
|
+
} else if (action === 'fit-content') {
|
|
156
|
+
ctx.actor.send({ type: 'SELECT_CARD', path: filePath, shift: false });
|
|
157
|
+
_updateSelectionHighlights(ctx);
|
|
158
|
+
_toggleCardExpand(ctx);
|
|
159
|
+
} else if (action === 'fit-screen') {
|
|
160
|
+
ctx.actor.send({ type: 'SELECT_CARD', path: filePath, shift: false });
|
|
161
|
+
_updateSelectionHighlights(ctx);
|
|
162
|
+
_fitScreenSize(ctx);
|
|
163
|
+
} else if (action === 'history') {
|
|
164
|
+
showFileHistory(ctx, filePath);
|
|
165
|
+
} else if (action === 'connect') {
|
|
166
|
+
// Start connection from this file
|
|
167
|
+
import('./connections').then(({ startConnectionFrom }) => {
|
|
168
|
+
if (startConnectionFrom) startConnectionFrom(ctx, filePath);
|
|
169
|
+
}).catch(() => {
|
|
170
|
+
showToast('Connections module not available', 'error');
|
|
171
|
+
});
|
|
172
|
+
} else if (action === 'pin') {
|
|
173
|
+
const nowPinned = togglePinCard(filePath);
|
|
174
|
+
if (nowPinned) {
|
|
175
|
+
card.dataset.pinned = 'true';
|
|
176
|
+
showToast(`📌 Pinned: ${filePath.split('/').pop()}`, 'info');
|
|
177
|
+
} else {
|
|
178
|
+
delete card.dataset.pinned;
|
|
179
|
+
showToast(`Unpinned: ${filePath.split('/').pop()}`, 'info');
|
|
180
|
+
}
|
|
181
|
+
// Trigger viewport culling to apply the change
|
|
182
|
+
import('./viewport-culling').then(m => m.scheduleViewportCulling(ctx));
|
|
183
|
+
} else if (action === 'delete') {
|
|
184
|
+
deleteFile(ctx, filePath, card);
|
|
185
|
+
} else if (action === 'rename') {
|
|
186
|
+
renameFile(ctx, filePath, card);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function handleActionLayer(layerId: string) {
|
|
191
|
+
menu.remove();
|
|
192
|
+
// Get all currently selected files for batch move
|
|
193
|
+
const selectedCards = ctx.snap().context.selectedCards || [];
|
|
194
|
+
const filesToMove = selectedCards.length > 1 ? selectedCards : [filePath];
|
|
195
|
+
|
|
196
|
+
if (layerId === 'new') {
|
|
197
|
+
const name = prompt('Enter a name for the new layer:');
|
|
198
|
+
if (!name) return;
|
|
199
|
+
createLayer(ctx, name);
|
|
200
|
+
const newLayerId = layerState.layers[layerState.layers.length - 1].id;
|
|
201
|
+
filesToMove.forEach(fp => moveFileToLayer(ctx, newLayerId, fp));
|
|
202
|
+
showToast(`Moved ${filesToMove.length} file(s) to "${name}"`, 'info');
|
|
203
|
+
} else {
|
|
204
|
+
const layer = layerState.layers.find(l => l.id === layerId);
|
|
205
|
+
filesToMove.forEach(fp => moveFileToLayer(ctx, layerId, fp));
|
|
206
|
+
showToast(`Moved ${filesToMove.length} file(s) to "${layer?.name || layerId}"`, 'info');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Handler for folder selection (recursive — selects all files under chosen directory)
|
|
211
|
+
function handleSelectFolder(dir: string) {
|
|
212
|
+
menu.remove();
|
|
213
|
+
const allPaths = Array.from(ctx.fileCards.keys());
|
|
214
|
+
const deferredPaths = Array.from(ctx.deferredCards.keys());
|
|
215
|
+
const allFilePaths = [...new Set([...allPaths, ...deferredPaths])];
|
|
216
|
+
const folderFiles = dir
|
|
217
|
+
? allFilePaths.filter(p => p.startsWith(dir + '/'))
|
|
218
|
+
: allFilePaths; // empty dir = root = select all
|
|
219
|
+
folderFiles.forEach((p, i) => {
|
|
220
|
+
ctx.actor.send({ type: 'SELECT_CARD', path: p, shift: i > 0 });
|
|
221
|
+
});
|
|
222
|
+
_updateSelectionHighlights(ctx);
|
|
223
|
+
_updateArrangeToolbar(ctx);
|
|
224
|
+
showToast(`Selected ${folderFiles.length} files from ${dir || 'root'}`, 'info');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const pinned = isPinned(filePath);
|
|
228
|
+
render(<ContextMenu onAction={handleAction} onActionLayer={handleActionLayer} onSelectFolder={handleSelectFolder} isInActiveLayer={isInActiveLayer} pinned={pinned} filePath={filePath} />, menu);
|
|
229
|
+
document.body.appendChild(menu);
|
|
230
|
+
|
|
231
|
+
requestAnimationFrame(() => {
|
|
232
|
+
const r = menu.getBoundingClientRect();
|
|
233
|
+
if (r.right > window.innerWidth) menu.style.left = `${window.innerWidth - r.width - 8}px`;
|
|
234
|
+
if (r.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - r.height - 8}px`;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const closeMenu = (e: MouseEvent) => {
|
|
238
|
+
if (!menu.contains(e.target as Node)) {
|
|
239
|
+
menu.remove();
|
|
240
|
+
document.removeEventListener('mousedown', closeMenu);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
setTimeout(() => document.addEventListener('mousedown', closeMenu), 0);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ─── File history panel (JSX) ───────────────────────
|
|
247
|
+
function FileHistoryContent({ fileName, commits, error, loading, onClose, onSelect }: {
|
|
248
|
+
fileName: string; commits: any[]; error?: string; loading: boolean;
|
|
249
|
+
onClose: () => void; onSelect: (hash: string) => void;
|
|
250
|
+
}) {
|
|
251
|
+
return (
|
|
252
|
+
<>
|
|
253
|
+
<div className="panel-header">
|
|
254
|
+
<span className="panel-title">History: {fileName}</span>
|
|
255
|
+
<button className="btn-ghost btn-xs" onClick={onClose}>✕</button>
|
|
256
|
+
</div>
|
|
257
|
+
<div className="file-history-list">
|
|
258
|
+
{loading ? (
|
|
259
|
+
<div style="padding: 16px; color: var(--text-muted); font-size: 0.75rem;">Loading...</div>
|
|
260
|
+
) : error ? (
|
|
261
|
+
<div style="padding: 16px; color: var(--error); font-size: 0.75rem;">Error: {error}</div>
|
|
262
|
+
) : commits.length === 0 ? (
|
|
263
|
+
<div style="padding: 16px; color: var(--text-muted); font-size: 0.75rem;">No commits found for this file</div>
|
|
264
|
+
) : (
|
|
265
|
+
commits.map(c => (
|
|
266
|
+
<div key={c.hash} className="file-history-item" onClick={() => onSelect(c.hash)}>
|
|
267
|
+
<span className="file-history-hash">{c.shortHash}</span>
|
|
268
|
+
<span className="file-history-msg">{c.message}</span>
|
|
269
|
+
<span className="file-history-date">{new Date(c.date).toLocaleDateString()}</span>
|
|
270
|
+
</div>
|
|
271
|
+
))
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
</>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function showFileHistory(ctx: CanvasContext, filePath: string) {
|
|
279
|
+
const state = ctx.snap().context;
|
|
280
|
+
if (!state.repoPath) {
|
|
281
|
+
console.warn('No repository loaded');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
document.querySelector('.file-history-panel')?.remove();
|
|
286
|
+
|
|
287
|
+
const panel = document.createElement('div');
|
|
288
|
+
panel.className = 'file-history-panel';
|
|
289
|
+
const fileName = filePath.split('/').pop() || filePath;
|
|
290
|
+
|
|
291
|
+
function closePanel() { panel.remove(); }
|
|
292
|
+
function selectCommitHash(hash: string) {
|
|
293
|
+
import('./repo').then(({ selectCommit }) => {
|
|
294
|
+
selectCommit(ctx, hash);
|
|
295
|
+
panel.remove();
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Initial loading state
|
|
300
|
+
render(<FileHistoryContent fileName={fileName} commits={[]} loading={true} onClose={closePanel} onSelect={selectCommitHash} />, panel);
|
|
301
|
+
document.querySelector('.canvas-area')?.appendChild(panel);
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const response = await fetch('/api/repo/file-history', {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: { 'Content-Type': 'application/json' },
|
|
307
|
+
body: JSON.stringify({ path: state.repoPath, filePath, limit: 30 })
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (!response.ok) throw new Error('Failed to fetch history');
|
|
311
|
+
const data = await response.json();
|
|
312
|
+
|
|
313
|
+
render(<FileHistoryContent fileName={fileName} commits={data.commits} loading={false} onClose={closePanel} onSelect={selectCommitHash} />, panel);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
render(<FileHistoryContent fileName={fileName} commits={[]} error={err.message} loading={false} onClose={closePanel} onSelect={selectCommitHash} />, panel);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── Delete file ────────────────────────────────────
|
|
320
|
+
async function deleteFile(ctx: CanvasContext, filePath: string, card: HTMLElement) {
|
|
321
|
+
const fileName = filePath.split('/').pop() || filePath;
|
|
322
|
+
const state = ctx.snap().context;
|
|
323
|
+
if (!state.repoPath) {
|
|
324
|
+
showToast('No repository loaded', 'error');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Show confirmation dialog
|
|
329
|
+
const confirmed = window.confirm(
|
|
330
|
+
`Delete "${fileName}"?\n\nPath: ${filePath}\n\nThis will permanently delete the file from disk.`
|
|
331
|
+
);
|
|
332
|
+
if (!confirmed) return;
|
|
333
|
+
|
|
334
|
+
// Ask if they want git rm
|
|
335
|
+
const useGitRm = window.confirm(
|
|
336
|
+
`Stage deletion with git?\n\nClick OK to use "git rm" (stages for commit).\nClick Cancel to just delete the file.`
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const res = await fetch('/api/repo/file-delete', {
|
|
341
|
+
method: 'POST',
|
|
342
|
+
headers: { 'Content-Type': 'application/json' },
|
|
343
|
+
body: JSON.stringify({
|
|
344
|
+
path: state.repoPath,
|
|
345
|
+
filePath,
|
|
346
|
+
gitRm: useGitRm,
|
|
347
|
+
}),
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
if (!res.ok) {
|
|
351
|
+
const err = await res.text();
|
|
352
|
+
showToast(`Delete failed: ${err}`, 'error');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Remove card from DOM and data structures
|
|
357
|
+
card.remove();
|
|
358
|
+
ctx.fileCards.delete(filePath);
|
|
359
|
+
ctx.positions.delete(filePath);
|
|
360
|
+
if (ctx.deferredCards) ctx.deferredCards.delete(filePath);
|
|
361
|
+
|
|
362
|
+
// Deselect if selected
|
|
363
|
+
const selected = ctx.snap().context.selectedCards;
|
|
364
|
+
if (selected.includes(filePath)) {
|
|
365
|
+
ctx.actor.send({ type: 'DESELECT_ALL' });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Remove from hidden files set if there
|
|
369
|
+
ctx.hiddenFiles.delete(filePath);
|
|
370
|
+
|
|
371
|
+
// Remove from allFilesData if present
|
|
372
|
+
if (ctx.allFilesData) {
|
|
373
|
+
ctx.allFilesData = ctx.allFilesData.filter(f => f.path !== filePath);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
showToast(`Deleted ${fileName}${useGitRm ? ' (staged)' : ''}`, 'success');
|
|
377
|
+
} catch (err: any) {
|
|
378
|
+
showToast(`Delete error: ${err.message}`, 'error');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ─── Rename / move file ─────────────────────────────
|
|
383
|
+
async function renameFile(ctx: CanvasContext, filePath: string, card: HTMLElement) {
|
|
384
|
+
const state = ctx.snap().context;
|
|
385
|
+
if (!state.repoPath) {
|
|
386
|
+
showToast('No repository loaded', 'error');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const newPath = window.prompt('Rename / Move file to:', filePath);
|
|
391
|
+
if (!newPath || newPath === filePath) return;
|
|
392
|
+
|
|
393
|
+
// Basic validation
|
|
394
|
+
if (newPath.includes('..') || newPath.startsWith('/')) {
|
|
395
|
+
showToast('Invalid path', 'error');
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const res = await fetch('/api/repo/file-rename', {
|
|
401
|
+
method: 'POST',
|
|
402
|
+
headers: { 'Content-Type': 'application/json' },
|
|
403
|
+
body: JSON.stringify({
|
|
404
|
+
path: state.repoPath,
|
|
405
|
+
oldPath: filePath,
|
|
406
|
+
newPath,
|
|
407
|
+
}),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
if (!res.ok) {
|
|
411
|
+
const err = await res.text();
|
|
412
|
+
showToast(`Rename failed: ${err}`, 'error');
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Update card DOM
|
|
417
|
+
card.dataset.path = newPath;
|
|
418
|
+
const nameEl = card.querySelector('.file-name, .card-filename');
|
|
419
|
+
if (nameEl) nameEl.textContent = newPath.split('/').pop() || newPath;
|
|
420
|
+
|
|
421
|
+
// Re-key internal data structures
|
|
422
|
+
const pos = ctx.positions.get(filePath);
|
|
423
|
+
if (pos) {
|
|
424
|
+
ctx.positions.delete(filePath);
|
|
425
|
+
ctx.positions.set(newPath, pos);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
ctx.fileCards.delete(filePath);
|
|
429
|
+
ctx.fileCards.set(newPath, card);
|
|
430
|
+
|
|
431
|
+
if (ctx.deferredCards) {
|
|
432
|
+
const deferred = ctx.deferredCards.get(filePath);
|
|
433
|
+
if (deferred) {
|
|
434
|
+
ctx.deferredCards.delete(filePath);
|
|
435
|
+
ctx.deferredCards.set(newPath, deferred);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Update allFilesData
|
|
440
|
+
if (ctx.allFilesData) {
|
|
441
|
+
const fileData = ctx.allFilesData.find(f => f.path === filePath);
|
|
442
|
+
if (fileData) {
|
|
443
|
+
fileData.path = newPath;
|
|
444
|
+
fileData.name = newPath.split('/').pop() || newPath;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const newName = newPath.split('/').pop() || newPath;
|
|
449
|
+
showToast(`Renamed → ${newName}`, 'success');
|
|
450
|
+
} catch (err: any) {
|
|
451
|
+
showToast(`Rename error: ${err.message}`, 'error');
|
|
452
|
+
}
|
|
453
|
+
}
|