experiments in a post-browser web
1Modified regular file .nvmrc:
2 1 1: 2224
3Modified regular file app/datastore/viewer.js:
4 1 1: // Datastore Viewer
5 2 2: import api from '../api.js';
6 3 : import izui from '../izui.js';
7 4 3:
8 5 4: console.log('datastore viewer loading');
9 6 5:
10 7 : // Initialize IZUI - this window is a child of settings, notify parent on close
11 8 : izui.init({ canHaveChildren: false });
12 9 :
13 10 6: const tables = ['addresses', 'visits', 'content', 'tags', 'blobs', 'scripts_data', 'feeds'];
14 11 7:
15 12 8: let currentTable = null;
16 ...
17Modified regular file app/diagnostic.html:
18 ...
19 47 47: <div id="output"></div>
20 48 48:
21 49 49: <script type="module">
22 50 : import izui from './izui.js';
23 51 :
24 52 50: const api = window.app;
25 53 51: const output = document.getElementById('output');
26 54 52:
27 55 : // Initialize IZUI - notify parent on close
28 56 : izui.init({ canHaveChildren: false });
29 57 :
30 58 53: function log(title, content) {
31 59 54: const section = document.createElement('div');
32 60 55: section.innerHTML = `<h2>${title}</h2><pre>${content}</pre>`;
33 ...
34Removed regular file app/izui.js:
35 1 : /**
36 2 : * IZUI: Inverted Zooming User Interface - Minimal Implementation
37 3 : *
38 4 : * Simple parent-child window tracking for focus restoration.
39 5 : * When a child window closes, focus returns to its parent.
40 6 : *
41 7 : * See notes/izui-model.md for the full design and future plans.
42 8 : */
43 9 :
44 10 : import api from './api.js';
45 11 :
46 12 : const DEBUG = false;
47 13 :
48 14 : /**
49 15 : * Notify parent window to take focus.
50 16 : * Called when this window is about to close/hide.
51 17 : *
52 18 : * @param {string} parentAddress - The parent window's address (source)
53 19 : */
54 20 : export function notifyParentFocus(parentAddress) {
55 21 : if (!parentAddress) return;
56 22 :
57 23 : DEBUG && console.log('[IZUI] Notifying parent to focus:', parentAddress);
58 24 :
59 25 : // Publish to the parent's address so it receives the message
60 26 : api.publish('izui:focus-request', {
61 27 : from: window.location.toString()
62 28 : }, api.scopes.GLOBAL);
63 29 : }
64 30 :
65 31 : /**
66 32 : * Set up focus listener for this window.
67 33 : * When a child window closes, this window will receive focus.
68 34 : *
69 35 : * Call this in windows that may have children (e.g., settings).
70 36 : */
71 37 : export function setupFocusListener() {
72 38 : const myAddress = window.location.toString();
73 39 :
74 40 : api.subscribe('izui:focus-request', (msg) => {
75 41 : DEBUG && console.log('[IZUI] Received focus request from:', msg?.from);
76 42 :
77 43 : // Focus this window
78 44 : if (api.window && api.window.focus) {
79 45 : api.window.focus({});
80 46 : }
81 47 : }, api.scopes.GLOBAL);
82 48 :
83 49 : DEBUG && console.log('[IZUI] Focus listener set up for:', myAddress);
84 50 : }
85 51 :
86 52 : /**
87 53 : * Open a child window with IZUI tracking.
88 54 : * The child will notify this window to focus when it closes.
89 55 : *
90 56 : * @param {string} address - URL to open
91 57 : * @param {Object} params - Window parameters
92 58 : * @returns {Promise<Object>} Window controller
93 59 : */
94 60 : export async function openChildWindow(address, params = {}) {
95 61 : const myAddress = window.location.toString();
96 62 :
97 63 : // Track this window as the parent
98 64 : const childParams = {
99 65 : ...params,
100 66 : izuiParent: myAddress
101 67 : };
102 68 :
103 69 : DEBUG && console.log('[IZUI] Opening child window:', address, 'parent:', myAddress);
104 70 :
105 71 : const result = await api.window.open(address, childParams);
106 72 : return result;
107 73 : }
108 74 :
109 75 : /**
110 76 : * Handle ESC key for IZUI navigation.
111 77 : * If the window has internal navigation, handle that first.
112 78 : * Otherwise, close and notify parent.
113 79 : *
114 80 : * @param {Function} [internalHandler] - Optional handler for internal navigation
115 81 : * Should return { handled: true } if it handled the ESC internally
116 82 : * @returns {Function} Cleanup function
117 83 : */
118 84 : export function setupEscapeHandler(internalHandler) {
119 85 : const handler = async () => {
120 86 : // First, try internal navigation
121 87 : if (internalHandler) {
122 88 : const result = await internalHandler();
123 89 : if (result && result.handled) {
124 90 : DEBUG && console.log('[IZUI] Internal handler handled ESC');
125 91 : return { handled: true };
126 92 : }
127 93 : }
128 94 :
129 95 : // No internal navigation, notify parent before closing
130 96 : // The parent address is passed via window params, but we need to get it
131 97 : // For now, we just publish globally - the parent will pick it up
132 98 : notifyParentFocus(null); // Global publish
133 99 :
134 100 : DEBUG && console.log('[IZUI] ESC at root, closing');
135 101 : return { handled: false };
136 102 : };
137 103 :
138 104 : // Register with the escape API
139 105 : if (api.escape && api.escape.onEscape) {
140 106 : api.escape.onEscape(handler);
141 107 : }
142 108 :
143 109 : return () => {
144 110 : // Cleanup - currently no way to unregister escape handler
145 111 : };
146 112 : }
147 113 :
148 114 : /**
149 115 : * Initialize IZUI for a window.
150 116 : * Sets up focus listening and escape handling.
151 117 : *
152 118 : * @param {Object} options
153 119 : * @param {Function} [options.onEscape] - Internal escape handler
154 120 : * @param {boolean} [options.canHaveChildren=true] - Whether this window may open children
155 121 : */
156 122 : export function init(options = {}) {
157 123 : const { onEscape, canHaveChildren = true } = options;
158 124 :
159 125 : if (canHaveChildren) {
160 126 : setupFocusListener();
161 127 : }
162 128 :
163 129 : if (onEscape) {
164 130 : setupEscapeHandler(onEscape);
165 131 : }
166 132 :
167 133 : DEBUG && console.log('[IZUI] Initialized for:', window.location.toString());
168 134 : }
169 135 :
170 136 : export default {
171 137 : init,
172 138 : setupFocusListener,
173 139 : setupEscapeHandler,
174 140 : openChildWindow,
175 141 : notifyParentFocus
176 142 : };
177Modified regular file app/settings/settings.js:
178 1 1: import appConfig from '../config.js';
179 2 2: import { createDatastoreStore } from '../utils.js';
180 3 3: import api from '../api.js';
181 4 4: import fc from '../features.js';
182 5 : import izui from '../izui.js';
183 6 5:
184 7 6: const DEBUG = api.debug;
185 8 7: const clear = false;
186 ...
1871212 1211: const allExtensions = [];
1881213 1212:
1891214 1213: // Get all builtin extension IDs from the loader
1901215 1214: const builtinExtIds = ['cmd', 'editor', 'groups', 'peeks', 'slides', 'windows'];
1911216 1215:
1921217 1216: // Add builtin extensions (whether running or not)
1931218 1217: builtinExtIds.forEach(extId => {
194 ...
1952524 2523: datastoreNav.textContent = 'Datastore';
1962525 2524: datastoreNav.style.cursor = 'pointer';
1972526 2525: datastoreNav.addEventListener('click', () => {
1982527 : izui.openChildWindow('peek://app/datastore/viewer.html', {
199 2526: api.window.open('peek://app/datastore/viewer.html', {
2002528 2527: width: 900,
2012529 2528: height: 600,
2022530 2529: key: 'datastore-viewer'
203 ...
2042538 2537: diagnosticNav.textContent = 'Diagnostic';
2052539 2538: diagnosticNav.style.cursor = 'pointer';
2062540 2539: diagnosticNav.addEventListener('click', () => {
2072541 : izui.openChildWindow('peek://app/diagnostic.html', {
208 2540: api.window.open('peek://app/diagnostic.html', {
2092542 2541: width: 900,
2102543 2542: height: 700,
2112544 2543: key: 'diagnostic-tool'
212 ...
2132574 2573:
2142575 2574: window.addEventListener('load', init);
2152576 2575:
2162577 : // Initialize IZUI for focus restoration when child windows close
2172578 : izui.init({ canHaveChildren: true });
2182579 :
2192580 2576: window.addEventListener('blur', () => {
2202581 2577: console.log('core settings blur');
2212582 2578: });
222 ...
223Modified regular file backend/tauri-mobile/src-tauri/gen/apple/Peek/ShareViewController.swift:
224 ...
225 9 9: /// Schema version for tracking compatibility between Rust main app and Swift Share Extension.
226 10 10: /// Increment this when making schema changes that both codepaths must understand.
227 11 11: /// Both Rust and Swift code should use the same version number.
228 12 12: private let SCHEMA_VERSION = "12"
229 13 13:
230 14 14: // MARK: - Item Types
231 15 15: enum ItemType: String {
232 ...
233 36 36: struct TagRecord: Codable, FetchableRecord, PersistableRecord {
234 37 37: static let databaseTableName = "tags"
235 38 38:
236 39 39: var id: Int64String?
237 40 40: var name: String
238 41 41: var frequency: Int
239 42 42: var lastUsed: String
240 ...
241 49 49: static let databaseTableName = "item_tags"
242 50 50:
243 51 51: var item_id: String
244 52 52: var tag_id: Int64String
245 53 53: var created_at: String
246 54 54: }
247 55 55:
248 ...
249 226 226:
250 227 227: CREATE TABLE IF NOT EXISTS item_tags (
251 228 228: item_id TEXT NOT NULL,
252 229 229: tag_id INTEGERTEXT NOT NULL,
253 230 230: created_at TEXT NOT NULL,
254 231 231: PRIMARY KEY (item_id, tag_id),
255 232 232: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
256 ...
257 272 272:
258 273 273: CREATE TABLE IF NOT EXISTS item_tags (
259 274 274: item_id TEXT NOT NULL,
260 275 275: tag_id INTEGERTEXT NOT NULL,
261 276 276: created_at TEXT NOT NULL,
262 277 277: PRIMARY KEY (item_id, tag_id),
263 278 278: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
264 ...
265 289 289: print("[DB] Metadata column added")
266 290 290: }
267 291 291:
268 292 : // Create tags and settings tables (always needed)
269 292: // Create settings table first (needed for migration tracking)
270 293 293: try db.execute(sql: """
271 294 : CREATE TABLE IF NOT EXISTS tags (
272 295 : id INTEGER PRIMARY KEY AUTOINCREMENT,
273 296 : name TEXT NOT NULL UNIQUE,
274 297 : frequency INTEGER NOT NULL DEFAULT 0,
275 298 : lastUsed TEXT NOT NULL,
276 299 : frecencyScore REAL NOT NULL DEFAULT 0.0,
277 300 : createdAt TEXT NOT NULL,
278 301 : updatedAt TEXT NOT NULL
279 302 : );
280 303 :
281 304 294: CREATE TABLE IF NOT EXISTS settings (
282 305 295: key TEXT PRIMARY KEY,
283 306 296: value TEXT NOT NULL
284 307 297: );
285 298: """)
286 299:
287 300: // Migrate tags.id from INTEGER to TEXT (for existing installs with old schema)
288 301: // Check if tags table exists and has INTEGER id column
289 302: if try db.tableExists("tags") {
290 303: let tagsHasIntegerId = try db.columns(in: "tags")
291 304: .contains { $0.name == "id" && $0.type.uppercased() == "INTEGER" }
292 305:
293 306: if tagsHasIntegerId {
294 307: print("[DB] Migrating tags.id from INTEGER to TEXT...")
295 308:
296 309: try db.inSavepoint {
297 310: // Step 1: Create temp mapping table
298 311: try db.execute(sql: """
299 312: CREATE TEMP TABLE tag_id_mapping (
300 313: old_id INTEGER PRIMARY KEY,
301 314: new_id TEXT NOT NULL
302 315: );
303 316: """)
304 317:
305 318: // Step 2: Populate mapping with generated TEXT IDs
306 319: let oldIds = try Int64.fetchAll(db, sql: "SELECT id FROM tags")
307 320: for oldId in oldIds {
308 321: let newId = generateTagId()
309 322: try db.execute(sql: "INSERT INTO tag_id_mapping (old_id, new_id) VALUES (?, ?)",
310 323: arguments: [oldId, newId])
311 324: }
312 325:
313 326: // Step 3: Create new tables with TEXT schema
314 327: try db.execute(sql: """
315 328: CREATE TABLE tags_new (
316 329: id TEXT PRIMARY KEY,
317 330: name TEXT NOT NULL UNIQUE,
318 331: frequency INTEGER NOT NULL DEFAULT 0,
319 332: lastUsed TEXT NOT NULL,
320 333: frecencyScore REAL NOT NULL DEFAULT 0.0,
321 334: createdAt TEXT NOT NULL,
322 335: updatedAt TEXT NOT NULL
323 336: );
324 337:
325 338: CREATE TABLE item_tags_new (
326 339: item_id TEXT NOT NULL,
327 340: tag_id TEXT NOT NULL,
328 341: created_at TEXT NOT NULL,
329 342: PRIMARY KEY (item_id, tag_id),
330 343: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
331 344: FOREIGN KEY (tag_id) REFERENCES tags_new(id) ON DELETE CASCADE
332 345: );
333 346: """)
334 347:
335 348: // Step 4: Copy data using mapping
336 349: try db.execute(sql: """
337 350: INSERT INTO tags_new (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt)
338 351: SELECT m.new_id, t.name, t.frequency, t.lastUsed, t.frecencyScore, t.createdAt, t.updatedAt
339 352: FROM tags t
340 353: JOIN tag_id_mapping m ON t.id = m.old_id;
341 354:
342 355: INSERT INTO item_tags_new (item_id, tag_id, created_at)
343 356: SELECT it.item_id, m.new_id, it.created_at
344 357: FROM item_tags it
345 358: JOIN tag_id_mapping m ON it.tag_id = m.old_id;
346 359: """)
347 360:
348 361: // Step 5: Swap tables
349 362: try db.execute(sql: """
350 363: DROP TABLE item_tags;
351 364: DROP TABLE tags;
352 365: ALTER TABLE tags_new RENAME TO tags;
353 366: ALTER TABLE item_tags_new RENAME TO item_tags;
354 367:
355 368: CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
356 369: CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC);
357 370:
358 371: DROP TABLE IF EXISTS tag_id_mapping;
359 372: """)
360 373:
361 374: print("[DB] Tags INTEGER→TEXT migration completed successfully")
362 375: return .commit
363 376: }
364 377: }
365 378: }
366 379:
367 380: // Create tags table (for fresh install or if migration skipped)
368 381: if !(try db.tableExists("tags")) {
369 382: try db.execute(sql: """
370 383: CREATE TABLE IF NOT EXISTS tags (
371 384: id TEXT PRIMARY KEY,
372 385: name TEXT NOT NULL UNIQUE,
373 386: frequency INTEGER NOT NULL DEFAULT 0,
374 387: lastUsed TEXT NOT NULL,
375 388: frecencyScore REAL NOT NULL DEFAULT 0.0,
376 389: createdAt TEXT NOT NULL,
377 390: updatedAt TEXT NOT NULL
378 391: );
379 392:
380 393: CREATE TABLE IF NOT EXISTS item_tags (
381 394: item_id TEXT NOT NULL,
382 395: tag_id TEXT NOT NULL,
383 396: created_at TEXT NOT NULL,
384 397: PRIMARY KEY (item_id, tag_id),
385 398: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
386 399: FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
387 400: );
388 401:
389 402: CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
390 403: CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC);
391 404: """)
392 405: }
393 406:
394 407: // Create blobs table and indexes
395 308 408: try db.execute(sql: """
396 309 409: CREATE TABLE IF NOT EXISTS blobs (
397 310 410: id TEXT PRIMARY KEY,
398 311 411: item_id TEXT NOT NULL,
399 ...
400 323 423: CREATE INDEX IF NOT EXISTS idx_items_url ON items(url);
401 324 424: CREATE INDEX IF NOT EXISTS idx_items_deleted ON items(deleted_at);
402 325 425: CREATE INDEX IF NOT EXISTS idx_items_sync_id ON items(sync_id);
403 326 : CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
404 327 : CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC);
405 328 426: CREATE INDEX IF NOT EXISTS idx_blobs_item_id ON blobs(item_id);
406 329 427: """)
407 330 428: }
408 ...
409 380 478: let wwwDomainPattern = "%://www.\(domain)/%"
410 381 479: let wwwDomainPatternRoot = "%://www.\(domain)"
411 382 480:
412 383 481: let domainTagIds = try Int64String.fetchSet(db, sql: """
413 384 482: SELECT DISTINCT it.tag_id
414 385 483: FROM item_tags it
415 386 484: JOIN items i ON it.item_id = i.id
416 ...
417 391 489:
418 392 490: // Apply 2x multiplier to tags used on same-domain URLs
419 393 491: let boostedTags = records.map { record -> TagStats in
420 394 492: let boostedScore = domainTagIds.contains(record.id ?? -1"")
421 395 493: ? record.frecencyScore * 2.0
422 396 494: : record.frecencyScore
423 397 495: return TagStats(
424 ...
425 497 595: // Add tags
426 498 596: for tagName in tags {
427 499 597: // Get or create tag
428 500 598: var tagId: Int64String
429 501 599:
430 502 600: if let existingTag = try TagRecord.filter(Column("name") == tagName).fetchOne(db) {
431 503 601: tagId = existingTag.id!
432 ...
433 512 610: """, arguments: [newFrequency, now, frecency, now, existingTag.id!])
434 513 611: } else {
435 514 612: // Create new tag
436 613: let newTagId = generateTagId()
437 515 614: let frecency = calculateFrecency(frequency: 1, lastUsed: now)
438 516 615: let newTag = TagRecord(id: nilnewTagId, name: tagName, frequency: 1, lastUsed: now, frecencyScore: frecency, createdAt: now, updatedAt: now)
439 517 616: try newTag.insert(db)
440 518 617: tagId = db.lastInsertedRowIDnewTagId
441 519 618: }
442 520 619:
443 521 620: // Create item-tag association
444 ...
445 566 665:
446 567 666: // Add tags
447 568 667: for tagName in tags {
448 569 668: var tagId: Int64String
449 570 669: let isNewToItem = !existingTagNames.contains(tagName)
450 571 670:
451 572 671: if let existingTag = try TagRecord.filter(Column("name") == tagName).fetchOne(db) {
452 ...
453 588 687: """, arguments: [now, frecency, now, existingTag.id!])
454 589 688: }
455 590 689: } else {
456 690: let newTagId = generateTagId()
457 591 691: let frecency = calculateFrecency(frequency: 1, lastUsed: now)
458 592 692: let newTag = TagRecord(id: nilnewTagId, name: tagName, frequency: 1, lastUsed: now, frecencyScore: frecency, createdAt: now, updatedAt: now)
459 593 693: try newTag.insert(db)
460 594 694: tagId = db.lastInsertedRowIDnewTagId
461 595 695: }
462 596 696:
463 597 697: let itemTag = ItemTagRecord(item_id: imageId, tag_id: tagId, created_at: now)
464 ...
465 688 788: // Add tags
466 689 789: for tagName in tags {
467 690 790: // Get or create tag
468 691 791: var tagId: Int64String
469 692 792: // Only increment frequency if this tag is new to this item
470 693 793: let isNewToItem = !existingTagNames.contains(tagName)
471 694 794:
472 ...
473 714 814: }
474 715 815: } else {
475 716 816: // Create new tag
476 817: let newTagId = generateTagId()
477 717 818: let frecency = calculateFrecency(frequency: 1, lastUsed: now)
478 718 819: let newTag = TagRecord(id: nilnewTagId, name: tagName, frequency: 1, lastUsed: now, frecencyScore: frecency, createdAt: now, updatedAt: now)
479 719 820: try newTag.insert(db)
480 720 821: tagId = db.lastInsertedRowIDnewTagId
481 721 822: }
482 722 823:
483 723 824: // Create item-tag association
484 ...
485 946 1047:
486 947 1048: return Double(frequency) * 10.0 * decayFactor
487 948 1049: }
488 1050:
489 1051: /// Generate a unique tag ID in format: tag_{timestamp}_{random9}
490 1052: private func generateTagId() -> String {
491 1053: let timestamp = Int(Date().timeIntervalSince1970 * 1000)
492 1054: let random = UUID().uuidString.prefix(9).lowercased()
493 1055: return "tag_\(timestamp)_\(random)"
494 1056: }
495 949 1057: }
496 950 1058:
497 951 1059: // MARK: - ISO8601 Formatter Extension
498 ...
499Modified regular file backend/tauri-mobile/src-tauri/src/lib.rs:
500 ...
501 918 918: /// Schema version for tracking compatibility between Rust main app and Swift Share Extension.
502 919 919: /// Increment this when making schema changes that both codepaths must understand.
503 920 920: /// Both Rust and Swift code should use the same version number.
504 921 921: const SCHEMA_VERSION: &str = "12";
505 922 922:
506 923 923: fn ensure_database_initialized() -> Result<(), String> {
507 924 924: let mut init_result: Result<(), String> = Ok(());
508 ...
5091037 1037: );
5101038 1038:
5111039 1039: CREATE TABLE IF NOT EXISTS tags (
5121040 1040: id INTEGERTEXT PRIMARY KEY AUTOINCREMENT,
5131041 1041: name TEXT NOT NULL UNIQUE,
5141042 1042: frequency INTEGER NOT NULL DEFAULT 0,
5151043 1043: lastUsed TEXT NOT NULL,
516 ...
5171048 1048:
5181049 1049: CREATE TABLE IF NOT EXISTS item_tags (
5191050 1050: item_id TEXT NOT NULL,
5201051 1051: tag_id INTEGERTEXT NOT NULL,
5211052 1052: created_at TEXT NOT NULL,
5221053 1053: PRIMARY KEY (item_id, tag_id),
5231054 1054: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
524 ...
5251203 1203: }
5261204 1204: }
5271205 1205:
5281206 : // Ensure tags and settings tables exist (for migration case)
529 1206: // Ensure settings table exists (for migration case)
5301207 1207: if let Err(e) = conn.execute_batch(
5311208 1208: "
5321209 : CREATE TABLE IF NOT EXISTS tags (
5331210 : id INTEGER PRIMARY KEY AUTOINCREMENT,
5341211 : name TEXT NOT NULL UNIQUE,
5351212 : frequency INTEGER NOT NULL DEFAULT 0,
5361213 : lastUsed TEXT NOT NULL,
5371214 : frecencyScore REAL NOT NULL DEFAULT 0.0,
5381215 : createdAt TEXT NOT NULL,
5391216 : updatedAt TEXT NOT NULL
5401217 : );
5411218 :
5421219 1209: CREATE TABLE IF NOT EXISTS settings (
5431220 1210: key TEXT PRIMARY KEY,
5441221 1211: value TEXT NOT NULL
5451222 1212: );
5461223 :
5471224 : CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
5481225 : CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC);
5491226 1213: ",
5501227 1214: ) {
5511228 : init_result = Err(format!("Failed to ensure auxiliary tables: {}", e));
552 1215: init_result = Err(format!("Failed to ensure settings table: {}", e));
5531229 1216: return;
5541230 1217: }
5551231 1218:
556 1219: // Migrate tags.id from INTEGER to TEXT (for existing installs with old schema)
557 1220: // Check if tags table exists and has INTEGER id column
558 1221: let tags_has_integer_id: bool = conn
559 1222: .query_row(
560 1223: "SELECT type FROM pragma_table_info('tags') WHERE name='id'",
561 1224: [],
562 1225: |row| row.get::<_, String>(0),
563 1226: )
564 1227: .map(|t| t.to_uppercase() == "INTEGER")
565 1228: .unwrap_or(false);
566 1229:
567 1230: if tags_has_integer_id {
568 1231: ios_log("Migrating tags.id from INTEGER to TEXT...");
569 1232:
570 1233: // Use savepoint for atomic rollback on error
571 1234: if let Err(e) = conn.execute("SAVEPOINT before_tag_migration", []) {
572 1235: ios_log(&format!("Failed to create savepoint: {}", e));
573 1236: } else {
574 1237: let migration_result: Result<(), String> = (|| {
575 1238: // Step 1: Create temp mapping table
576 1239: conn.execute_batch(
577 1240: "CREATE TEMP TABLE tag_id_mapping (
578 1241: old_id INTEGER PRIMARY KEY,
579 1242: new_id TEXT NOT NULL
580 1243: );"
581 1244: ).map_err(|e| format!("Failed to create mapping table: {}", e))?;
582 1245:
583 1246: // Step 2: Populate mapping with generated TEXT IDs
584 1247: let mut stmt = conn.prepare("SELECT id FROM tags")
585 1248: .map_err(|e| format!("Failed to prepare tag select: {}", e))?;
586 1249: let old_ids: Vec<i64> = stmt
587 1250: .query_map([], |row| row.get::<_, i64>(0))
588 1251: .map_err(|e| format!("Failed to query tags: {}", e))?
589 1252: .filter_map(|r| r.ok())
590 1253: .collect();
591 1254: drop(stmt);
592 1255:
593 1256: for old_id in old_ids {
594 1257: let new_id = generate_tag_id();
595 1258: conn.execute(
596 1259: "INSERT INTO tag_id_mapping (old_id, new_id) VALUES (?, ?)",
597 1260: params![old_id, new_id],
598 1261: ).map_err(|e| format!("Failed to insert mapping: {}", e))?;
599 1262: }
600 1263:
601 1264: // Step 3: Create new tables with TEXT schema
602 1265: conn.execute_batch(
603 1266: "CREATE TABLE tags_new (
604 1267: id TEXT PRIMARY KEY,
605 1268: name TEXT NOT NULL UNIQUE,
606 1269: frequency INTEGER NOT NULL DEFAULT 0,
607 1270: lastUsed TEXT NOT NULL,
608 1271: frecencyScore REAL NOT NULL DEFAULT 0.0,
609 1272: createdAt TEXT NOT NULL,
610 1273: updatedAt TEXT NOT NULL
611 1274: );
612 1275:
613 1276: CREATE TABLE item_tags_new (
614 1277: item_id TEXT NOT NULL,
615 1278: tag_id TEXT NOT NULL,
616 1279: created_at TEXT NOT NULL,
617 1280: PRIMARY KEY (item_id, tag_id),
618 1281: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
619 1282: FOREIGN KEY (tag_id) REFERENCES tags_new(id) ON DELETE CASCADE
620 1283: );"
621 1284: ).map_err(|e| format!("Failed to create new tables: {}", e))?;
622 1285:
623 1286: // Step 4: Copy data using mapping
624 1287: conn.execute_batch(
625 1288: "INSERT INTO tags_new (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt)
626 1289: SELECT m.new_id, t.name, t.frequency, t.lastUsed, t.frecencyScore, t.createdAt, t.updatedAt
627 1290: FROM tags t
628 1291: JOIN tag_id_mapping m ON t.id = m.old_id;
629 1292:
630 1293: INSERT INTO item_tags_new (item_id, tag_id, created_at)
631 1294: SELECT it.item_id, m.new_id, it.created_at
632 1295: FROM item_tags it
633 1296: JOIN tag_id_mapping m ON it.tag_id = m.old_id;"
634 1297: ).map_err(|e| format!("Failed to copy data: {}", e))?;
635 1298:
636 1299: // Step 5: Swap tables
637 1300: conn.execute_batch(
638 1301: "DROP TABLE item_tags;
639 1302: DROP TABLE tags;
640 1303: ALTER TABLE tags_new RENAME TO tags;
641 1304: ALTER TABLE item_tags_new RENAME TO item_tags;
642 1305:
643 1306: CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
644 1307: CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC);"
645 1308: ).map_err(|e| format!("Failed to swap tables: {}", e))?;
646 1309:
647 1310: // Cleanup temp table
648 1311: let _ = conn.execute("DROP TABLE IF EXISTS tag_id_mapping", []);
649 1312:
650 1313: Ok(())
651 1314: })();
652 1315:
653 1316: match migration_result {
654 1317: Ok(()) => {
655 1318: let _ = conn.execute("RELEASE SAVEPOINT before_tag_migration", []);
656 1319: ios_log("Tags INTEGER→TEXT migration completed successfully");
657 1320: }
658 1321: Err(e) => {
659 1322: ios_log(&format!("Tags migration failed, rolling back: {}", e));
660 1323: let _ = conn.execute("ROLLBACK TO SAVEPOINT before_tag_migration", []);
661 1324: let _ = conn.execute("RELEASE SAVEPOINT before_tag_migration", []);
662 1325: }
663 1326: }
664 1327: }
665 1328: }
666 1329:
667 1330: // Ensure tags table exists (for fresh install or if migration skipped)
668 1331: let has_tags_table: bool = conn
669 1332: .query_row(
670 1333: "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='tags'",
671 1334: [],
672 1335: |row| row.get::<_, i64>(0),
673 1336: )
674 1337: .unwrap_or(0) > 0;
675 1338:
676 1339: if !has_tags_table {
677 1340: if let Err(e) = conn.execute_batch(
678 1341: "
679 1342: CREATE TABLE IF NOT EXISTS tags (
680 1343: id TEXT PRIMARY KEY,
681 1344: name TEXT NOT NULL UNIQUE,
682 1345: frequency INTEGER NOT NULL DEFAULT 0,
683 1346: lastUsed TEXT NOT NULL,
684 1347: frecencyScore REAL NOT NULL DEFAULT 0.0,
685 1348: createdAt TEXT NOT NULL,
686 1349: updatedAt TEXT NOT NULL
687 1350: );
688 1351:
689 1352: CREATE TABLE IF NOT EXISTS item_tags (
690 1353: item_id TEXT NOT NULL,
691 1354: tag_id TEXT NOT NULL,
692 1355: created_at TEXT NOT NULL,
693 1356: PRIMARY KEY (item_id, tag_id),
694 1357: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
695 1358: FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
696 1359: );
697 1360:
698 1361: CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
699 1362: CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC);
700 1363: ",
701 1364: ) {
702 1365: init_result = Err(format!("Failed to create tags tables: {}", e));
703 1366: return;
704 1367: }
705 1368: }
706 1369:
7071232 1370: // One-time dedup migration: remove duplicate items
7081233 1371: let dedup_done: bool = conn
7091234 1372: .query_row(
710 ...
7111467 1605: frequency as f64 * 10.0 * decay_factor
7121468 1606: }
7131469 1607:
714 1608: // Generate a unique tag ID in format: tag_{timestamp}_{random9}
715 1609: fn generate_tag_id() -> String {
716 1610: use std::time::{SystemTime, UNIX_EPOCH};
717 1611: let timestamp = SystemTime::now()
718 1612: .duration_since(UNIX_EPOCH)
719 1613: .unwrap()
720 1614: .as_millis();
721 1615: let uuid = uuid::Uuid::new_v4().to_string();
722 1616: let random = &uuid[0..9]; // First 9 chars of UUID
723 1617: format!("tag_{}_{}", timestamp, random)
724 1618: }
725 1619:
7261470 1620: // Helper to get webhook config
7271471 1621: fn get_webhook_config() -> (Option<String>, Option<String>) {
7281472 1622: let config = load_profile_config();
729 ...
7301915 2065: // Add tags
7311916 2066: for tag_name in &tags {
7321917 2067: // Get or create tag
7331918 2068: let tag_id: i64String = match conn.query_row(
7341919 2069: "SELECT id FROM tags WHERE name = ?",
7351920 2070: params![tag_name],
7361921 2071: |row| row.get(0),
737 ...
7381925 2075: let frequency: u32 = conn
7391926 2076: .query_row(
7401927 2077: "SELECT frequency FROM tags WHERE id = ?",
7411928 2078: params![&id],
7421929 2079: |row| row.get(0),
7431930 2080: )
7441931 2081: .unwrap_or(0);
745 ...
7461935 2085:
7471936 2086: conn.execute(
7481937 2087: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?",
7491938 2088: params![new_frequency, &now, frecency, &now, &id],
7501939 2089: )
7511940 2090: .map_err(|e| format!("Failed to update tag: {}", e))?;
7521941 2091:
7531942 2092: id
7541943 2093: }
7551944 2094: Err(_) => {
7561945 2095: // Create new tag
757 2096: let new_tag_id = generate_tag_id();
7581946 2097: let frecency = calculate_frecency(1, &now);
7591947 2098: conn.execute(
7601948 2099: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)",
7611949 2100: params![&new_tag_id, tag_name, &now, frecency, &now, &now],
7621950 2101: )
7631951 2102: .map_err(|e| format!("Failed to insert tag: {}", e))?;
7641952 2103:
7651953 2104: conn.last_insert_rowid()new_tag_id
7661954 2105: }
7671955 2106: };
7681956 2107:
769 ...
7702025 2176: .prepare("SELECT id, name, frequency, lastUsed, frecencyScore FROM tags")
7712026 2177: .map_err(|e| format!("Failed to prepare query: {}", e))?;
7722027 2178:
7732028 2179: let tags_with_ids: Vec<(i64String, TagStats)> = stmt
7742029 2180: .query_map([], |row| {
7752030 2181: Ok((
7762031 2182: row.get(0)?,
777 ...
7782057 2208: )
7792058 2209: .map_err(|e| format!("Failed to prepare domain query: {}", e))?;
7802059 2210:
7812060 2211: let domain_tag_ids: std::collections::HashSet<i64String> = domain_stmt
7822061 2212: .query_map(
7832062 2213: params![&domain_pattern, &domain_pattern_root, &www_domain_pattern, &www_domain_pattern_root],
7842063 2214: |row| row.get(0),
785 ...
7862213 2364: // Add only the tags that are new to this item
7872214 2365: for tag_name in &tags_to_add {
7882215 2366: // Get or create tag
7892216 2367: let tag_id: i64String = match conn.query_row(
7902217 2368: "SELECT id FROM tags WHERE name = ?",
7912218 2369: params![tag_name],
7922219 2370: |row| row.get(0),
793 ...
7942223 2374: let frequency: u32 = conn
7952224 2375: .query_row(
7962225 2376: "SELECT frequency FROM tags WHERE id = ?",
7972226 2377: params![&existing_id],
7982227 2378: |row| row.get(0),
7992228 2379: )
8002229 2380: .unwrap_or(0);
801 ...
8022233 2384:
8032234 2385: conn.execute(
8042235 2386: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?",
8052236 2387: params![new_frequency, &now, frecency, &now, &existing_id],
8062237 2388: )
8072238 2389: .map_err(|e| format!("Failed to update tag: {}", e))?;
8082239 2390:
8092240 2391: existing_id
8102241 2392: }
8112242 2393: Err(_) => {
8122243 2394: // Create new tag
813 2395: let new_tag_id = generate_tag_id();
8142244 2396: let frecency = calculate_frecency(1, &now);
8152245 2397: conn.execute(
8162246 2398: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)",
8172247 2399: params![&new_tag_id, tag_name, &now, frecency, &now, &now],
8182248 2400: )
8192249 2401: .map_err(|e| format!("Failed to insert tag: {}", e))?;
8202250 2402:
8212251 2403: conn.last_insert_rowid()new_tag_id
8222252 2404: }
8232253 2405: };
8242254 2406:
825 ...
8262339 2491: // Add only the tags that are new to this item
8272340 2492: for tag_name in &tags_to_add {
8282341 2493: // Get or create tag
8292342 2494: let tag_id: i64String = match conn.query_row(
8302343 2495: "SELECT id FROM tags WHERE name = ?",
8312344 2496: params![tag_name],
8322345 2497: |row| row.get(0),
833 ...
8342349 2501: let frequency: u32 = conn
8352350 2502: .query_row(
8362351 2503: "SELECT frequency FROM tags WHERE id = ?",
8372352 2504: params![&existing_id],
8382353 2505: |row| row.get(0),
8392354 2506: )
8402355 2507: .unwrap_or(0);
841 ...
8422359 2511:
8432360 2512: conn.execute(
8442361 2513: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?",
8452362 2514: params![new_frequency, &now, frecency, &now, &existing_id],
8462363 2515: )
8472364 2516: .map_err(|e| format!("Failed to update tag: {}", e))?;
8482365 2517:
8492366 2518: existing_id
8502367 2519: }
8512368 2520: Err(_) => {
8522369 2521: // Create new tag
853 2522: let new_tag_id = generate_tag_id();
8542370 2523: let frecency = calculate_frecency(1, &now);
8552371 2524: conn.execute(
8562372 2525: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)",
8572373 2526: params![&new_tag_id, tag_name, &now, frecency, &now, &now],
8582374 2527: )
8592375 2528: .map_err(|e| format!("Failed to insert tag: {}", e))?;
8602376 2529:
8612377 2530: conn.last_insert_rowid()new_tag_id
8622378 2531: }
8632379 2532: };
8642380 2533:
865 ...
8662439 2592:
8672440 2593: // Add tags
8682441 2594: for tag_name in &all_tags {
8692442 2595: let tag_id: i64String = match conn.query_row(
8702443 2596: "SELECT id FROM tags WHERE name = ?",
8712444 2597: params![tag_name],
8722445 2598: |row| row.get(0),
873 ...
8742448 2601: let frequency: u32 = conn
8752449 2602: .query_row(
8762450 2603: "SELECT frequency FROM tags WHERE id = ?",
8772451 2604: params![&existing_id],
8782452 2605: |row| row.get(0),
8792453 2606: )
8802454 2607: .unwrap_or(0);
881 ...
8822458 2611:
8832459 2612: conn.execute(
8842460 2613: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?",
8852461 2614: params![new_frequency, &now, frecency, &now, &existing_id],
8862462 2615: )
8872463 2616: .map_err(|e| format!("Failed to update tag: {}", e))?;
8882464 2617:
8892465 2618: existing_id
8902466 2619: }
8912467 2620: Err(_) => {
892 2621: let new_tag_id = generate_tag_id();
8932468 2622: let frecency = calculate_frecency(1, &now);
8942469 2623: conn.execute(
8952470 2624: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)",
8962471 2625: params![&new_tag_id, tag_name, &now, frecency, &now, &now],
8972472 2626: )
8982473 2627: .map_err(|e| format!("Failed to insert tag: {}", e))?;
8992474 2628:
9002475 2629: conn.last_insert_rowid()new_tag_id
9012476 2630: }
9022477 2631: };
9032478 2632:
904 ...
9052516 2670:
9062517 2671: // Add tags
9072518 2672: for tag_name in &tags {
9082519 2673: let tag_id: i64String = match conn.query_row(
9092520 2674: "SELECT id FROM tags WHERE name = ?",
9102521 2675: params![tag_name],
9112522 2676: |row| row.get(0),
912 ...
9132525 2679: let frequency: u32 = conn
9142526 2680: .query_row(
9152527 2681: "SELECT frequency FROM tags WHERE id = ?",
9162528 2682: params![&existing_id],
9172529 2683: |row| row.get(0),
9182530 2684: )
9192531 2685: .unwrap_or(0);
920 ...
9212535 2689:
9222536 2690: conn.execute(
9232537 2691: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?",
9242538 2692: params![new_frequency, &now, frecency, &now, &existing_id],
9252539 2693: )
9262540 2694: .map_err(|e| format!("Failed to update tag: {}", e))?;
9272541 2695:
9282542 2696: existing_id
9292543 2697: }
9302544 2698: Err(_) => {
931 2699: let new_tag_id = generate_tag_id();
9322545 2700: let frecency = calculate_frecency(1, &now);
9332546 2701: conn.execute(
9342547 2702: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)",
9352548 2703: params![&new_tag_id, tag_name, &now, frecency, &now, &now],
9362549 2704: )
9372550 2705: .map_err(|e| format!("Failed to insert tag: {}", e))?;
9382551 2706:
9392552 2707: conn.last_insert_rowid()new_tag_id
9402553 2708: }
9412554 2709: };
9422555 2710:
943 ...
9442727 2882:
9452728 2883: // Add new tags
9462729 2884: for tag_name in &tags_to_add {
9472730 2885: let tag_id: i64String = match conn.query_row(
9482731 2886: "SELECT id FROM tags WHERE name = ?",
9492732 2887: params![tag_name],
9502733 2888: |row| row.get(0),
951 ...
9522736 2891: let frequency: u32 = conn
9532737 2892: .query_row(
9542738 2893: "SELECT frequency FROM tags WHERE id = ?",
9552739 2894: params![&existing_id],
9562740 2895: |row| row.get(0),
9572741 2896: )
9582742 2897: .unwrap_or(0);
959 ...
9602746 2901:
9612747 2902: conn.execute(
9622748 2903: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?",
9632749 2904: params![new_frequency, &now, frecency, &now, &existing_id],
9642750 2905: )
9652751 2906: .map_err(|e| format!("Failed to update tag: {}", e))?;
9662752 2907:
9672753 2908: existing_id
9682754 2909: }
9692755 2910: Err(_) => {
970 2911: let new_tag_id = generate_tag_id();
9712756 2912: let frecency = calculate_frecency(1, &now);
9722757 2913: conn.execute(
9732758 2914: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)",
9742759 2915: params![&new_tag_id, tag_name, &now, frecency, &now, &now],
9752760 2916: )
9762761 2917: .map_err(|e| format!("Failed to insert tag: {}", e))?;
9772762 2918:
9782763 2919: conn.last_insert_rowid()new_tag_id
9792764 2920: }
9802765 2921: };
9812766 2922:
982 ...
9832851 3007:
9842852 3008: // Add new tags
9852853 3009: for tag_name in &tags_to_add {
9862854 3010: let tag_id: i64String = match conn.query_row(
9872855 3011: "SELECT id FROM tags WHERE name = ?",
9882856 3012: params![tag_name],
9892857 3013: |row| row.get(0),
990 ...
9912860 3016: let frequency: u32 = conn
9922861 3017: .query_row(
9932862 3018: "SELECT frequency FROM tags WHERE id = ?",
9942863 3019: params![&existing_id],
9952864 3020: |row| row.get(0),
9962865 3021: )
9972866 3022: .unwrap_or(0);
998 ...
9992870 3026:
10002871 3027: conn.execute(
10012872 3028: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?",
10022873 3029: params![new_frequency, &now, frecency, &now, &existing_id],
10032874 3030: )
10042875 3031: .map_err(|e| format!("Failed to update tag: {}", e))?;
10052876 3032:
10062877 3033: existing_id
10072878 3034: }
10082879 3035: Err(_) => {
1009 3036: let new_tag_id = generate_tag_id();
10102880 3037: let frecency = calculate_frecency(1, &now);
10112881 3038: conn.execute(
10122882 3039: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)",
10132883 3040: params![&new_tag_id, tag_name, &now, frecency, &now, &now],
10142884 3041: )
10152885 3042: .map_err(|e| format!("Failed to insert tag: {}", e))?;
10162886 3043:
10172887 3044: conn.last_insert_rowid()new_tag_id
10182888 3045: }
10192889 3046: };
10202890 3047:
1021 ...
10222959 3116:
10232960 3117: // Add tags
10242961 3118: for tag_name in &tags {
10252962 3119: let tag_id: i64String = match conn.query_row(
10262963 3120: "SELECT id FROM tags WHERE name = ?",
10272964 3121: params![tag_name],
10282965 3122: |row| row.get(0),
1029 ...
10302968 3125: let frequency: u32 = conn
10312969 3126: .query_row(
10322970 3127: "SELECT frequency FROM tags WHERE id = ?",
10332971 3128: params![&existing_id],
10342972 3129: |row| row.get(0),
10352973 3130: )
10362974 3131: .unwrap_or(0);
1037 ...
10382978 3135:
10392979 3136: conn.execute(
10402980 3137: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?",
10412981 3138: params![new_frequency, &now, frecency, &now, &existing_id],
10422982 3139: )
10432983 3140: .map_err(|e| format!("Failed to update tag: {}", e))?;
10442984 3141:
10452985 3142: existing_id
10462986 3143: }
10472987 3144: Err(_) => {
1048 3145: let new_tag_id = generate_tag_id();
10492988 3146: let frecency = calculate_frecency(1, &now);
10502989 3147: conn.execute(
10512990 3148: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)",
10522991 3149: params![&new_tag_id, tag_name, &now, frecency, &now, &now],
10532992 3150: )
10542993 3151: .map_err(|e| format!("Failed to insert tag: {}", e))?;
10552994 3152:
10562995 3153: conn.last_insert_rowid()new_tag_id
10572996 3154: }
10582997 3155: };
10592998 3156:
1060 ...
10613169 3327:
10623170 3328: // Add new tags
10633171 3329: for tag_name in &tags_to_add {
10643172 3330: let tag_id: i64String = match conn.query_row(
10653173 3331: "SELECT id FROM tags WHERE name = ?",
10663174 3332: params![tag_name],
10673175 3333: |row| row.get(0),
1068 ...
10693178 3336: let frequency: u32 = conn
10703179 3337: .query_row(
10713180 3338: "SELECT frequency FROM tags WHERE id = ?",
10723181 3339: params![&existing_id],
10733182 3340: |row| row.get(0),
10743183 3341: )
10753184 3342: .unwrap_or(0);
1076 ...
10773188 3346:
10783189 3347: conn.execute(
10793190 3348: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?",
10803191 3349: params![new_frequency, &now, frecency, &now, &existing_id],
10813192 3350: )
10823193 3351: .map_err(|e| format!("Failed to update tag: {}", e))?;
10833194 3352:
10843195 3353: existing_id
10853196 3354: }
10863197 3355: Err(_) => {
1087 3356: let new_tag_id = generate_tag_id();
10883198 3357: let frecency = calculate_frecency(1, &now);
10893199 3358: conn.execute(
10903200 3359: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)",
10913201 3360: params![&new_tag_id, tag_name, &now, frecency, &now, &now],
10923202 3361: )
10933203 3362: .map_err(|e| format!("Failed to insert tag: {}", e))?;
10943204 3363:
10953205 3364: conn.last_insert_rowid()new_tag_id
10963206 3365: }
10973207 3366: };
10983208 3367:
1099 ...
11003930 4089: // Add new tags
11013931 4090: for tag_name in tag_names {
11023932 4091: // Get or create tag
11033933 4092: let tag_id: i64String = match conn.query_row(
11043934 4093: "SELECT id FROM tags WHERE name = ?",
11053935 4094: params![tag_name],
11063936 4095: |row| row.get(0),
11073937 4096: ) {
11083938 4097: Ok(id) => {
11093939 4098: // Update existing tag stats
11103940 4099: let frequency: u32 = conn
11113941 4100: .query_row("SELECT frequency FROM tags WHERE id = ?", params![&id], |row| row.get(0))
11123942 4101: .unwrap_or(0);
11133943 4102:
11143944 4103: let new_frequency = frequency + 1;
11153945 4104: let frecency = calculate_frecency(new_frequency, &now);
11163946 4105:
11173947 4106: conn.execute(
11183948 4107: "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?",
11193949 4108: params![new_frequency, &now, frecency, &now, &id],
11203950 4109: ).ok();
11213951 4110:
11223952 4111: id
11233953 4112: }
11243954 4113: Err(_) => {
11253955 4114: // Create new tag
1126 4115: let new_tag_id = generate_tag_id();
11273956 4116: let frecency = calculate_frecency(1, &now);
11283957 4117: conn.execute(
11293958 4118: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)",
11303959 4119: params![&new_tag_id, tag_name, &now, frecency, &now, &now],
11313960 4120: )
11323961 4121: .map_err(|e| format!("Failed to insert tag: {}", e))?;
11333962 4122:
11343963 4123: conn.last_insert_rowid()new_tag_id
11353964 4124: }
11363965 4125: };
11373966 4126:
1138 ...
11394429 4589: CREATE INDEX IF NOT EXISTS idx_items_sync_id ON items(sync_id);
11404430 4590:
11414431 4591: CREATE TABLE IF NOT EXISTS tags (
11424432 4592: id INTEGERTEXT PRIMARY KEY AUTOINCREMENT,
11434433 4593: name TEXT NOT NULL UNIQUE,
11444434 4594: frequency INTEGER NOT NULL DEFAULT 0,
11454435 4595: lastUsed TEXT NOT NULL,
1146 ...
11474440 4600:
11484441 4601: CREATE TABLE IF NOT EXISTS item_tags (
11494442 4602: item_id TEXT NOT NULL,
11504443 4603: tag_id INTEGERTEXT NOT NULL,
11514444 4604: created_at TEXT NOT NULL,
11524445 4605: PRIMARY KEY (item_id, tag_id),
11534446 4606: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
1154 ...
11554470 4630: continue;
11564471 4631: }
11574472 4632:
11584473 4633: let tag_id: i64String = match conn.query_row(
11594474 4634: "SELECT id FROM tags WHERE name = ?",
11604475 4635: params![&normalized],
11614476 4636: |row| row.get(0),
11624477 4637: ) {
11634478 4638: Ok(existing_id) => existing_id,
11644479 4639: Err(_) => {
1165 4640: let new_tag_id = generate_tag_id();
11664480 4641: conn.execute(
11674481 4642: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, 10.0, ?, ?)",
11684482 4643: params![&new_tag_id, &normalized, &now, &now, &now],
11694483 4644: )
11704484 4645: .expect("Failed to insert tag");
11714485 4646: conn.last_insert_rowid()new_tag_id
11724486 4647: }
11734487 4648: };
11744488 4649:
1175 ...
11764619 4780: continue;
11774620 4781: }
11784621 4782:
11794622 4783: let tag_id: i64String = match conn.query_row(
11804623 4784: "SELECT id FROM tags WHERE name = ?",
11814624 4785: params![&normalized],
11824625 4786: |row| row.get(0),
11834626 4787: ) {
11844627 4788: Ok(existing_id) => existing_id,
11854628 4789: Err(_) => {
1186 4790: let new_tag_id = generate_tag_id();
11874629 4791: conn.execute(
11884630 4792: "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, 10.0, ?, ?)",
11894631 4793: params![&new_tag_id, &normalized, &now, &now, &now],
11904632 4794: )
11914633 4795: .expect("Failed to insert tag");
11924634 4796: conn.last_insert_rowid()new_tag_id
11934635 4797: }
11944636 4798: };
11954637 4799:
1196 ...
1197Removed regular file docs/editor-remaining-work.md:
1198 1 : # Editor Extension - Remaining Work
1199 2 :
1200 3 : Research note documenting what's implemented and what's missing compared to peek-edit.
1201 4 :
1202 5 : ## Implemented Features
1203 6 :
1204 7 : ### Folditall-Style Folding
1205 8 : - Header folding (all 6 levels) - folds until next same/higher level
1206 9 : - List item folding - nested lists with children are foldable
1207 10 : - Fenced code block folding (``` and ~~~)
1208 11 : - Vim fold commands work from any line within a fold region
1209 12 :
1210 13 : ### Vim Fold Commands
1211 14 : - `za` - toggle fold
1212 15 : - `zc` - close/fold
1213 16 : - `zo` - open/unfold
1214 17 : - `zM` - fold all
1215 18 : - `zR` - unfold all
1216 19 : - `zm` / `zr` - simplified level-based (same as zM/zR)
1217 20 : - `<Space>` - toggle fold (folditall behavior)
1218 21 :
1219 22 : ### Status Line
1220 23 : - Mode indicator: NORMAL (yellow), INSERT (green), VISUAL (purple)
1221 24 : - Cursor position: `Ln X, Col Y`
1222 25 : - Only visible when vim mode is enabled
1223 26 :
1224 27 : ### Three-Panel Layout
1225 28 : - Outline sidebar (left) - TOC from headers
1226 29 : - CodeMirror editor (center)
1227 30 : - Preview sidebar (right) - live markdown rendering
1228 31 : - Resizable panels
1229 32 : - Focus mode (hides sidebars)
1230 33 :
1231 34 : ## Missing Features (from peek-edit)
1232 35 :
1233 36 : ### High Priority - Core Vim
1234 37 :
1235 38 : | Feature | Description |
1236 39 : |---------|-------------|
1237 40 : | Text Objects | `iw`, `aw`, `is`, `as`, `ip`, `ap`, `i"`, `a"`, `i(`, `a(`, `i{`, `a{` |
1238 41 : | Character Search | `f`, `F`, `t`, `T`, `;`, `,` |
1239 42 : | Word Search | `*` (search word under cursor forward), `#` (backward) |
1240 43 : | Replace Mode | `R` (continuous replace until Escape) |
1241 44 : | Bracket Motion | `%` (jump to matching bracket) |
1242 45 : | Paragraph Motion | `{`, `}` (jump between paragraphs) |
1243 46 : | Viewport Motion | `H` (high), `M` (middle), `L` (low of viewport) |
1244 47 :
1245 48 : **Note:** Many of these may already be in `@replit/codemirror-vim` - needs verification.
1246 49 :
1247 50 : ### Medium Priority - Ex Commands
1248 51 :
1249 52 : | Command | Description |
1250 53 : |---------|-------------|
1251 54 : | `:s/pat/repl/g` | Search and replace with flags |
1252 55 : | `:123` | Go to line 123 |
1253 56 : | `:noh` / `:nohlsearch` | Clear search highlighting |
1254 57 : | `:zen` / `:focus` | Enter focus/zen mode |
1255 58 : | `:outline` / `:ol` | Toggle outline sidebar |
1256 59 : | `:preview` / `:pv` | Toggle preview sidebar |
1257 60 : | `:sidebars` / `:sb` | Toggle both sidebars |
1258 61 : | `:narrow` / `:na` | Centered narrow layout mode |
1259 62 :
1260 63 : ### Medium Priority - Editing Commands
1261 64 :
1262 65 : | Command | Description |
1263 66 : |---------|-------------|
1264 67 : | `gq{motion}` | Wrap text at 78 chars, preserve indent |
1265 68 : | `gJ` | Join lines without space |
1266 69 : | `gu{motion}` | Lowercase |
1267 70 : | `gU{motion}` | Uppercase |
1268 71 : | `g~{motion}` | Toggle case |
1269 72 :
1270 73 : ### Low Priority - Advanced
1271 74 :
1272 75 : | Feature | Description |
1273 76 : |---------|-------------|
1274 77 : | Marks | `ma` (set mark), `'a` / `` `a `` (jump to mark) |
1275 78 : | Macros | `qa` (record), `q` (stop), `@a` (play), `@@` (repeat) |
1276 79 : | Visual Block | `<C-v>` column selection |
1277 80 : | Tab Completion | Tab-complete ex commands |
1278 81 :
1279 82 : ## Implementation Notes
1280 83 :
1281 84 : ### @replit/codemirror-vim
1282 85 :
1283 86 : The vim plugin likely already provides many motions and text objects. Before implementing, check:
1284 87 : 1. What motions/text objects are already available
1285 88 : 2. How to add custom ex commands via `Vim.defineEx()`
1286 89 : 3. Whether `Vim.map()` can extend missing features
1287 90 :
1288 91 : ### Ex Command Integration
1289 92 :
1290 93 : To add UI control via ex commands:
1291 94 : ```javascript
1292 95 : Vim.defineEx('zen', '', (cm) => {
1293 96 : // Toggle focus mode
1294 97 : });
1295 98 :
1296 99 : Vim.defineEx('outline', 'ol', (cm) => {
1297 100 : // Toggle outline sidebar
1298 101 : });
1299 102 : ```
1300 103 :
1301 104 : ### Narrow Mode
1302 105 :
1303 106 : Narrow mode centers the editor with max-width constraint. CSS approach:
1304 107 : ```css
1305 108 : .narrow-mode .editor-container {
1306 109 : max-width: 720px;
1307 110 : margin: 0 auto;
1308 111 : }
1309 112 : ```
1310 113 :
1311 114 : ## Test Coverage
1312 115 :
1313 116 : Current: 33 tests covering:
1314 117 : - Editor setup (3)
1315 118 : - Fold all/unfold all (3)
1316 119 : - Header folding behavior (4)
1317 120 : - Vim fold commands (5)
1318 121 : - Click-to-fold (1)
1319 122 : - List item folding (5)
1320 123 : - Code block folding (2)
1321 124 : - Spacebar toggle (1)
1322 125 : - Fold from any line (2)
1323 126 : - Nested behavior (2)
1324 127 : - Status line (5)
1325 128 :
1326 129 : ## Files
1327 130 :
1328 131 : - `extensions/editor/codemirror.js` - Main editor with folditall algorithm
1329 132 : - `extensions/editor/editor-layout.js` - Three-panel layout
1330 133 : - `extensions/editor/status-line.js` - Vim status bar
1331 134 : - `extensions/editor/outline-sidebar.js` - TOC sidebar
1332 135 : - `extensions/editor/preview-sidebar.js` - Markdown preview
1333 136 : - `tests/editor/editor-folding.spec.ts` - 33 tests
1334 137 : - `tests/editor/test-page.html` - Test harness
1335Modified regular file extensions/editor/codemirror.js:
1336 1 1: /**
1337 2 2: * CodeMirror Editor Module
1338 3 3: *
1339 4 4: * Provides a configured CodeMirror instance for markdown editing
1340 5 5: * with optional vim mode support and folditall-style folding.
1341 6 6: */
1342 7 7:
1343 8 8: import { EditorState, Compartment } from '@codemirror/state';
1344 9 9: import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection } from '@codemirror/view';
1345 10 10: import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
1346 11 11: import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
1347 12 12: import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, indentOnInput, foldGutter, foldService, codeFolding, foldAll, unfoldAll, foldEffect, unfoldEffect, foldedRanges, foldable } from '@codemirror/language';
1348 13 13: import { oneDark } from '@codemirror/theme-one-dark';
1349 14 14: import { vim, Vim, getCM } from '@replit/codemirror-vim';
1350 15 15: import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
1351 16 16:
1352 17 17: // Compartments for runtime-reconfigurable extensions
1353 18 18: const vimCompartment = new Compartment();
1354 19 19: const themeCompartment = new Compartment();
1355 20 20:
1356 21 : // ============================================================================
1357 22 : // Folditall Algorithm Helpers
1358 23 : // ============================================================================
1359 24 :
1360 25 : /**
1361 26 : * Get the header level (1-6) from a line, or 0 if not a header.
1362 27 : */
1363 28 : function getHeaderLevel(text) {
1364 29 : const match = text.match(/^(#{1,6})\s+/);
1365 30 : return match ? match[1].length : 0;
1366 31 : }
1367 32 :
1368 33 : /**
1369 34 : * Check if a line is a list item (bullet or numbered).
1370 35 : */
1371 36 : function isListItem(text) {
1372 37 : return /^\s*[-*+]\s/.test(text) || /^\s*\d+[.)]\s/.test(text);
1373 38 : }
1374 39 :
1375 40 : /**
1376 41 : * Get the indentation level of a line (number of leading spaces/tabs).
1377 42 : * Tabs count as 2 spaces.
1378 43 : */
1379 44 : function getIndent(text) {
1380 45 : let indent = 0;
1381 46 : for (const char of text) {
1382 47 : if (char === ' ') indent++;
1383 48 : else if (char === '\t') indent += 2;
1384 49 : else break;
1385 50 : }
1386 51 : return indent;
1387 52 : }
1388 53 :
1389 54 : /**
1390 55 : * Check if a line is blank or whitespace-only.
1391 56 : */
1392 57 : function isBlankLine(text) {
1393 58 : return /^\s*$/.test(text);
1394 59 : }
1395 60 :
1396 61 : /**
1397 62 : * Check if a line is a fenced code block opener (``` or ~~~).
1398 63 : */
1399 64 : function isFencedCodeBlockOpener(text) {
1400 65 : return /^(`{3,}|~{3,})/.test(text.trim());
1401 66 : }
1402 67 :
1403 68 : /**
1404 69 : * Find the closing fence for a fenced code block.
1405 70 : * Returns line number of closing fence, or null if not found.
1406 71 : */
1407 72 : function findClosingFence(doc, openerLineNum) {
1408 73 : const openerLine = doc.line(openerLineNum);
1409 74 : const openerText = openerLine.text.trim();
1410 75 : const match = openerText.match(/^(`{3,}|~{3,})/);
1411 76 : if (!match) return null;
1412 77 :
1413 78 : const fenceChar = match[1][0];
1414 79 : const fenceLen = match[1].length;
1415 80 : const totalLines = doc.lines;
1416 81 :
1417 82 : for (let i = openerLineNum + 1; i <= totalLines; i++) {
1418 83 : const line = doc.line(i);
1419 84 : const trimmed = line.text.trim();
1420 85 : // Closing fence must be same char and at least same length
1421 86 : const closeMatch = trimmed.match(new RegExp(`^${fenceChar}{${fenceLen},}$`));
1422 87 : if (closeMatch) {
1423 88 : return i;
1424 89 : }
1425 90 : }
1426 91 : return null;
1427 92 : }
1428 93 :
1429 94 : /**
1430 95 : * Find the next non-blank line number after lineNum.
1431 96 : * Returns null if no non-blank line exists.
1432 97 : */
1433 98 : function findNextNonBlank(doc, lineNum) {
1434 99 : const totalLines = doc.lines;
1435 100 : for (let i = lineNum + 1; i <= totalLines; i++) {
1436 101 : const line = doc.line(i);
1437 102 : if (!isBlankLine(line.text)) {
1438 103 : return i;
1439 104 : }
1440 105 : }
1441 106 : return null;
1442 107 : }
1443 108 :
1444 109 : /**
1445 110 : * Check if a line can start a fold based on folditall rules:
1446 111 : * - Headers always can start folds
1447 112 : * - List items can start folds (if they have children)
1448 113 : * - Fenced code block openers can start folds
1449 114 : * - Indent-0 lines can start folds (if they have indented children)
1450 115 : */
1451 116 : function canStartFold(text) {
1452 117 : if (getHeaderLevel(text) > 0) return true;
1453 118 : if (isListItem(text)) return true;
1454 119 : if (isFencedCodeBlockOpener(text)) return true;
1455 120 : if (getIndent(text) === 0 && !isBlankLine(text)) return true;
1456 121 : return false;
1457 122 : }
1458 123 :
1459 124 : /**
1460 125 : * Check if a line has foldable children (more-indented content following it).
1461 126 : */
1462 127 : function hasFoldableChildren(doc, lineNum) {
1463 128 : const line = doc.line(lineNum);
1464 129 : const text = line.text;
1465 130 :
1466 131 : // Headers always have children (until next same/higher level header)
1467 132 : if (getHeaderLevel(text) > 0) return true;
1468 133 :
1469 134 : // Fenced code blocks have children if there's a closing fence
1470 135 : if (isFencedCodeBlockOpener(text)) {
1471 136 : return findClosingFence(doc, lineNum) !== null;
1472 137 : }
1473 138 :
1474 139 : const currentIndent = getIndent(text);
1475 140 : const nextNonBlankNum = findNextNonBlank(doc, lineNum);
1476 141 :
1477 142 : if (nextNonBlankNum === null) return false;
1478 143 :
1479 144 : const nextLine = doc.line(nextNonBlankNum);
1480 145 : const nextText = nextLine.text;
1481 146 :
1482 147 : // Next line must be more indented (and not a header)
1483 148 : if (getHeaderLevel(nextText) > 0) return false;
1484 149 :
1485 150 : return getIndent(nextText) > currentIndent;
1486 151 : }
1487 152 :
1488 153 : /**
1489 154 : * Find the end of a fold region starting at lineNum.
1490 155 : * For headers: ends at next header of same or higher level.
1491 156 : * For fenced code blocks: ends at the closing fence.
1492 157 : * For list/indent: ends when indentation returns to same or lower level.
1493 158 : */
1494 159 : function findFoldEnd(doc, lineNum) {
1495 160 : const line = doc.line(lineNum);
1496 161 : const text = line.text;
1497 162 : const totalLines = doc.lines;
1498 163 : const headerLevel = getHeaderLevel(text);
1499 164 :
1500 165 : if (headerLevel > 0) {
1501 166 : // Header fold: ends at next header of same or higher level
1502 167 : for (let i = lineNum + 1; i <= totalLines; i++) {
1503 168 : const checkLine = doc.line(i);
1504 169 : const checkLevel = getHeaderLevel(checkLine.text);
1505 170 : if (checkLevel > 0 && checkLevel <= headerLevel) {
1506 171 : return i - 1;
1507 172 : }
1508 173 : }
1509 174 : return totalLines;
1510 175 : }
1511 176 :
1512 177 : // Fenced code block fold: ends at closing fence
1513 178 : if (isFencedCodeBlockOpener(text)) {
1514 179 : const closingLine = findClosingFence(doc, lineNum);
1515 180 : return closingLine !== null ? closingLine : lineNum;
1516 181 : }
1517 182 :
1518 183 : // List/indent fold: ends when indentation returns to same or lower level
1519 184 : const startIndent = getIndent(text);
1520 185 : let lastContentLine = lineNum;
1521 186 :
1522 187 : for (let i = lineNum + 1; i <= totalLines; i++) {
1523 188 : const checkLine = doc.line(i);
1524 189 : const checkText = checkLine.text;
1525 190 :
1526 191 : // Skip blank lines but track last content
1527 192 : if (isBlankLine(checkText)) continue;
1528 193 :
1529 194 : // Headers break indent folds
1530 195 : if (getHeaderLevel(checkText) > 0) {
1531 196 : return lastContentLine;
1532 197 : }
1533 198 :
1534 199 : const checkIndent = getIndent(checkText);
1535 200 :
1536 201 : // If indent is same or less, fold ends at previous content line
1537 202 : if (checkIndent <= startIndent) {
1538 203 : return lastContentLine;
1539 204 : }
1540 205 :
1541 206 : lastContentLine = i;
1542 207 : }
1543 208 :
1544 209 : return lastContentLine;
1545 210 : }
1546 211 :
1547 212 : /**
1548 213 : * Folditall-style folding: find the fold region containing a line.
1549 214 : * Searches backwards to find the nearest fold-starting line that contains this line.
1550 215 : */
1551 216 : function findContainingFoldStart(state, lineNum) {
1552 217 : const doc = state.doc;
1553 218 : const currentLine = doc.line(lineNum);
1554 219 : const currentText = currentLine.text;
1555 220 :
1556 221 : // If current line can start a fold and has children, return it
1557 222 : if (canStartFold(currentText) && hasFoldableChildren(doc, lineNum)) {
1558 223 : return currentLine.from;
1559 224 : }
1560 225 :
1561 226 : const currentIndent = getIndent(currentText);
1562 227 :
1563 228 : // Search backwards for a containing fold region
1564 229 : for (let i = lineNum - 1; i >= 1; i--) {
1565 230 : const line = doc.line(i);
1566 231 : const text = line.text;
1567 232 :
1568 233 : // Skip blank lines
1569 234 : if (isBlankLine(text)) continue;
1570 235 :
1571 236 : const lineIndent = getIndent(text);
1572 237 : const headerLevel = getHeaderLevel(text);
1573 238 :
1574 239 : // Headers always contain following content (until next same-level header)
1575 240 : if (headerLevel > 0) {
1576 241 : // Check if this header's fold extends to our line
1577 242 : const foldEnd = findFoldEnd(doc, i);
1578 243 : if (foldEnd >= lineNum) {
1579 244 : return line.from;
1580 245 : }
1581 246 : continue;
1582 247 : }
1583 248 :
1584 249 : // List items or indent-0 lines with less indent could contain us
1585 250 : if (lineIndent < currentIndent && canStartFold(text) && hasFoldableChildren(doc, i)) {
1586 251 : const foldEnd = findFoldEnd(doc, i);
1587 252 : if (foldEnd >= lineNum) {
1588 253 : return line.from;
1589 254 : }
1590 255 : }
1591 256 : }
1592 257 :
1593 258 : return null;
1594 259 : }
1595 260 :
1596 261 : /**
1597 262 : * Check if a position is inside a folded range.
1598 263 : */
1599 264 : function isPositionFolded(state, pos) {
1600 265 : const folded = foldedRanges(state);
1601 266 : let found = false;
1602 267 : folded.between(0, state.doc.length, (from, to) => {
1603 268 : if (pos >= from && pos <= to) {
1604 269 : found = true;
1605 270 : }
1606 271 : });
1607 272 : return found;
1608 273 : }
1609 274 :
1610 275 : /**
1611 276 : * Find the fold range at a position (if folded).
1612 277 : */
1613 278 : function findFoldedRangeAt(state, pos) {
1614 279 : const folded = foldedRanges(state);
1615 280 : let result = null;
1616 281 : folded.between(0, state.doc.length, (from, to) => {
1617 282 : if (pos >= from && pos <= to) {
1618 283 : result = { from, to };
1619 284 : }
1620 285 : });
1621 286 : return result;
1622 287 : }
1623 288 :
1624 289 : // Define vim fold commands using folditall-style region finding
1625 290 : Vim.defineAction('foldAll', (cm) => {
1626 291 : foldAll(cm.cm6);
1627 292 : });
1628 293 :
1629 294 : Vim.defineAction('unfoldAll', (cm) => {
1630 295 : unfoldAll(cm.cm6);
1631 296 : });
1632 297 :
1633 298 : Vim.defineAction('foldCode', (cm) => {
1634 299 : const view = cm.cm6;
1635 300 : const pos = view.state.selection.main.head;
1636 301 : const lineNum = view.state.doc.lineAt(pos).number;
1637 302 :
1638 303 : // Find the containing fold region's start
1639 304 : const foldStart = findContainingFoldStart(view.state, lineNum);
1640 305 : if (foldStart !== null) {
1641 306 : // Get the foldable range at the fold start
1642 307 : const foldRange = foldable(view.state, foldStart, foldStart);
1643 308 : if (foldRange) {
1644 309 : // Create fold effect
1645 310 : view.dispatch({
1646 311 : effects: foldEffect.of({ from: foldRange.from, to: foldRange.to })
1647 312 : });
1648 313 : }
1649 314 : }
1650 315 : });
1651 316 :
1652 317 : Vim.defineAction('unfoldCode', (cm) => {
1653 318 : const view = cm.cm6;
1654 319 : const pos = view.state.selection.main.head;
1655 320 : const line = view.state.doc.lineAt(pos);
1656 321 : const lineNum = line.number;
1657 322 :
1658 323 : // First check if we're in a folded range (cursor inside fold)
1659 324 : const foldedRange = findFoldedRangeAt(view.state, pos);
1660 325 : if (foldedRange) {
1661 326 : view.dispatch({
1662 327 : effects: unfoldEffect.of({ from: foldedRange.from, to: foldedRange.to })
1663 328 : });
1664 329 : return;
1665 330 : }
1666 331 :
1667 332 : // Check if there's a fold starting at the end of current line (we're on the fold line)
1668 333 : const foldAtLineEnd = findFoldedRangeAt(view.state, line.to);
1669 334 : if (foldAtLineEnd) {
1670 335 : view.dispatch({
1671 336 : effects: unfoldEffect.of({ from: foldAtLineEnd.from, to: foldAtLineEnd.to })
1672 337 : });
1673 338 : return;
1674 339 : }
1675 340 :
1676 341 : // Find the containing fold region and try to unfold it
1677 342 : const foldStart = findContainingFoldStart(view.state, lineNum);
1678 343 : if (foldStart !== null) {
1679 344 : const foldStartLine = view.state.doc.lineAt(foldStart);
1680 345 : const foldRange = foldable(view.state, foldStartLine.from, foldStartLine.to);
1681 346 : if (foldRange) {
1682 347 : // Check if this range is folded
1683 348 : const folded = foldedRanges(view.state);
1684 349 : let isFolded = false;
1685 350 : folded.between(foldRange.from, foldRange.to, (from, to) => {
1686 351 : if (from === foldRange.from) {
1687 352 : isFolded = true;
1688 353 : }
1689 354 : });
1690 355 : if (isFolded) {
1691 356 : view.dispatch({
1692 357 : effects: unfoldEffect.of({ from: foldRange.from, to: foldRange.to })
1693 358 : });
1694 359 : }
1695 360 : }
1696 361 : }
1697 362 : });
1698 363 :
1699 364 : Vim.defineAction('toggleFold', (cm) => {
1700 365 : const view = cm.cm6;
1701 366 : const pos = view.state.selection.main.head;
1702 367 : const lineNum = view.state.doc.lineAt(pos).number;
1703 368 :
1704 369 : // Find the containing fold region's start
1705 370 : const foldStart = findContainingFoldStart(view.state, lineNum);
1706 371 : if (foldStart === null) return;
1707 372 :
1708 373 : const line = view.state.doc.lineAt(foldStart);
1709 374 : const foldRange = foldable(view.state, line.from, line.to);
1710 375 : if (!foldRange) return;
1711 376 :
1712 377 : // Check if this range is currently folded
1713 378 : const folded = foldedRanges(view.state);
1714 379 : let isFolded = false;
1715 380 : folded.between(foldRange.from, foldRange.to, (from, to) => {
1716 381 : if (from === foldRange.from) {
1717 382 : isFolded = true;
1718 383 : }
1719 384 : });
1720 385 :
1721 386 : if (isFolded) {
1722 387 : view.dispatch({
1723 388 : effects: unfoldEffect.of({ from: foldRange.from, to: foldRange.to })
1724 389 : });
1725 390 : } else {
1726 391 : view.dispatch({
1727 392 : effects: foldEffect.of({ from: foldRange.from, to: foldRange.to })
1728 393 : });
1729 394 : }
1730 395 : });
1731 396 :
1732 397 : // Map vim fold commands
1733 398 : Vim.mapCommand('zc', 'action', 'foldCode', {}, { context: 'normal' });
1734 399 : Vim.mapCommand('zo', 'action', 'unfoldCode', {}, { context: 'normal' });
1735 400 : Vim.mapCommand('za', 'action', 'toggleFold', {}, { context: 'normal' });
1736 401 : Vim.mapCommand('zM', 'action', 'foldAll', {}, { context: 'normal' });
1737 402 : Vim.mapCommand('zR', 'action', 'unfoldAll', {}, { context: 'normal' });
1738 403 : // zr and zm are level-based - simplified to same as zR/zM for now
1739 404 : Vim.mapCommand('zr', 'action', 'unfoldAll', {}, { context: 'normal' });
1740 405 : Vim.mapCommand('zm', 'action', 'foldAll', {}, { context: 'normal' });
1741 406 : // Space toggles fold (like za) - folditall behavior
1742 407 : Vim.mapCommand('<Space>', 'action', 'toggleFold', {}, { context: 'normal' });
1743 408 :
1744 409 21: /**
1745 410 22: * Create a peek-themed CodeMirror theme using CSS variables
1746 411 23: */
1747 ...
1748 515 127: }, { dark: true });
1749 516 128:
1750 517 129: /**
1751 518 : * Folditall-style fold service.
1752 519 : * Handles:
1753 520 : * - Markdown headers (fold to next same/higher level header)
1754 521 : * - List items with children (fold nested content)
1755 522 : * - Indent-0 lines with indented children (code blocks, etc.)
1756 523 : */
1757 524 : const folditallFoldService = foldService.of((state, lineStart, lineEnd) => {
1758 525 : const doc = state.doc;
1759 526 : const line = doc.lineAt(lineStart);
1760 527 : const text = line.text;
1761 528 : const lineNum = line.number;
1762 529 :
1763 530 : // Skip blank lines
1764 531 : if (isBlankLine(text)) return null;
1765 532 :
1766 533 : // Check if this line can start a fold and has children
1767 534 : if (!canStartFold(text)) return null;
1768 535 : if (!hasFoldableChildren(doc, lineNum)) return null;
1769 536 :
1770 537 : // Find fold end
1771 538 : const endLineNum = findFoldEnd(doc, lineNum);
1772 539 :
1773 540 : // Don't fold if there's nothing to fold
1774 541 : if (endLineNum <= lineNum) return null;
1775 542 :
1776 543 : const endLine = doc.line(endLineNum);
1777 544 :
1778 545 : // Fold from end of starting line to end of last line in section
1779 546 : return { from: line.to, to: endLine.to };
1780 547 : });
1781 548 :
1782 549 : /**
1783 550 130: * Create a CodeMirror editor instance
1784 551 131: * @param {Object} options - Configuration options
1785 552 132: * @param {HTMLElement} options.parent - Parent element to mount editor in
1786 553 133: * @param {string} options.content - Initial content
1787 554 134: * @param {boolean} options.vimMode - Enable vim mode
1788 555 135: * @param {boolean} options.showLineNumbers - Show line numbers
1789 556 136: * @param {Function} options.onChange - Callback when content changes
1790 557 : * @param {Function} options.onSelectionChange - Callback when cursor position changes (line, col)
1791 558 : * @param {Function} options.onVimModeChange - Callback when vim mode changes (mode string)
1792 559 137: * @returns {EditorView} - CodeMirror EditorView instance
1793 560 138: */
1794 561 139: export function createEditor({ parent, content = '', vimMode = false, showLineNumbers = true, onChange, onSelectionChange, onVimModeChange }) {
1795 562 : // Track last known vim mode to detect changes
1796 563 : let lastVimMode = 'normal';
1797 564 :
1798 565 140: const extensions = [
1799 566 141: // Core extensions
1800 567 142: history(),
1801 ...
1802 584 159: markdown({ base: markdownLanguage }),
1803 585 160: syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
1804 586 161:
1805 587 : // Folding support (required for vim fold commands)
1806 588 : codeFolding(),
1807 589 : folditallFoldService,
1808 590 :
1809 591 162: // Theming
1810 592 163: themeCompartment.of(peekTheme),
1811 593 164:
1812 594 165: // Vim mode (initially based on setting)
1813 595 166: vimCompartment.of(vimMode ? vim() : []),
1814 596 167:
1815 597 168: // UpdateChange listener for content, selection, and vim mode changes
1816 598 169: EditorView.updateListener.of(update => {
1817 599 : // Content change
1818 600 170: if (update.docChanged && onChange) {
1819 601 171: onChange(update.state.doc.toString());
1820 602 172: }
1821 603 :
1822 604 : // Selection/cursor change
1823 605 : if (update.selectionSet && onSelectionChange) {
1824 606 : const pos = update.state.selection.main.head;
1825 607 : const line = update.state.doc.lineAt(pos);
1826 608 : const col = pos - line.from + 1;
1827 609 : onSelectionChange(line.number, col);
1828 610 : }
1829 611 :
1830 612 : // Vim mode change detection
1831 613 : if (onVimModeChange && vimMode) {
1832 614 : try {
1833 615 : const cm = getCM(update.view);
1834 616 : if (cm && cm.state && cm.state.vim) {
1835 617 : const vimState = cm.state.vim;
1836 618 : let currentMode = 'normal';
1837 619 :
1838 620 : if (vimState.insertMode) {
1839 621 : currentMode = 'insert';
1840 622 : } else if (vimState.visualMode) {
1841 623 : currentMode = vimState.visualLine ? 'visual-line' :
1842 624 : vimState.visualBlock ? 'visual-block' : 'visual';
1843 625 : } else if (vimState.mode === 'replace') {
1844 626 : currentMode = 'replace';
1845 627 : }
1846 628 :
1847 629 : if (currentMode !== lastVimMode) {
1848 630 : lastVimMode = currentMode;
1849 631 : onVimModeChange(currentMode);
1850 632 : }
1851 633 : }
1852 634 : } catch (e) {
1853 635 : // Vim not active
1854 636 : }
1855 637 : }
1856 638 173: }),
1857 639 174: ];
1858 640 175:
1859 ...
1860 653 188: parent,
1861 654 189: });
1862 655 190:
1863 656 : // Initial position callback
1864 657 : if (onSelectionChange) {
1865 658 : const pos = view.state.selection.main.head;
1866 659 : const line = view.state.doc.lineAt(pos);
1867 660 : const col = pos - line.from + 1;
1868 661 : onSelectionChange(line.number, col);
1869 662 : }
1870 663 :
1871 664 : // Initial vim mode callback
1872 665 : if (onVimModeChange && vimMode) {
1873 666 : onVimModeChange('normal');
1874 667 : }
1875 668 :
1876 669 191: return view;
1877 670 192: }
1878 671 193:
1879 ...
1880Modified regular file extensions/editor/editor-layout.js:
1881 ...
1882 6 6:
1883 7 7: import { OutlineSidebar } from './outline-sidebar.js';
1884 8 8: import { PreviewSidebar } from './preview-sidebar.js';
1885 9 : import { StatusLine } from './status-line.js';
1886 10 9: import * as CodeMirror from './codemirror.js';
1887 11 10:
1888 12 11: export class EditorLayout {
1889 13 12: constructor(options) {
1890 14 13: this.container = options.container;
1891 15 14: this.onContentChange = options.onContentChange;
1892 16 : this.onVimModeChange = options.onVimModeChange;
1893 17 15: this.initialContent = options.initialContent || '';
1894 18 16: this.vimMode = options.vimMode || false;
1895 19 17:
1896 20 18: this.outlineSidebar = null;
1897 21 19: this.previewSidebar = null;
1898 22 : this.statusLine = null;
1899 23 20: this.cmEditor = null;
1900 24 21: this.lastContent = '';
1901 25 22: this.rafId = null;
1902 ...
1903 54 51: this.cmContainer.className = 'cm-container';
1904 55 52: this.editorContainer.appendChild(this.cmContainer);
1905 56 53:
1906 57 : // Status line container (below editor, above toolbar)
1907 58 : this.statusLineContainer = document.createElement('div');
1908 59 : this.statusLineContainer.className = 'status-line-container';
1909 60 : this.editorContainer.appendChild(this.statusLineContainer);
1910 61 :
1911 62 54: // Toolbar below editor
1912 63 55: this.toolbar = document.createElement('div');
1913 64 56: this.toolbar.className = 'editor-toolbar';
1914 65 57:
1915 58: // Vim mode toggle
1916 59: this.vimToggle = document.createElement('label');
1917 60: this.vimToggle.className = 'vim-toggle';
1918 61:
1919 62: this.vimCheckbox = document.createElement('input');
1920 63: this.vimCheckbox.type = 'checkbox';
1921 64: this.vimCheckbox.checked = this.vimMode;
1922 65: this.vimCheckbox.addEventListener('change', () => this.handleVimToggle());
1923 66:
1924 67: const vimLabel = document.createElement('span');
1925 68: vimLabel.textContent = 'Vim';
1926 69:
1927 70: this.vimToggle.appendChild(this.vimCheckbox);
1928 71: this.vimToggle.appendChild(vimLabel);
1929 72: this.toolbar.appendChild(this.vimToggle);
1930 73:
1931 66 74: // Sidebar toggles
1932 67 75: const sidebarToggles = document.createElement('div');
1933 68 76: sidebarToggles.className = 'sidebar-toggles';
1934 ...
1935 104 112:
1936 105 113: this.container.appendChild(this.wrapper);
1937 106 114:
1938 107 : // Initialize status line (only shown when vim mode is enabled)
1939 108 : this.statusLine = new StatusLine({
1940 109 : container: this.statusLineContainer,
1941 110 : });
1942 111 :
1943 112 : // Hide status line initially if vim mode is off
1944 113 : if (!this.vimMode) {
1945 114 : this.statusLine.hide();
1946 115 : }
1947 116 :
1948 117 115: // Initialize CodeMirror
1949 118 116: this.cmEditor = CodeMirror.createEditor({
1950 119 117: parent: this.cmContainer,
1951 120 118: content: this.initialContent,
1952 121 119: vimMode: this.vimMode,
1953 122 120: showLineNumbers: true,
1954 123 121: onChange: (content) => this.handleContentChange(content),
1955 124 : onSelectionChange: (line, col) => this.handleSelectionChange(line, col),
1956 125 : onVimModeChange: (mode) => this.handleVimModeUpdate(mode),
1957 126 122: });
1958 127 123:
1959 128 124: // Default sidebars to collapsed
1960 ...
1961 274 270: }
1962 275 271: }
1963 276 272:
1964 277 273: /**
1965 278 : * Update vim mode state (called from setVimMode).
1966 279 : */
1967 280 273: updateVimModeStatehandleVimToggle(enabled) {
1968 281 274: this.vimMode = enabledthis.vimCheckbox.checked;
1969 282 275: if (this.cmEditor) {
1970 283 276: CodeMirror.setVimMode(this.cmEditor, this.vimMode);
1971 284 277: }
1972 285 :
1973 286 : // Show/hide status line based on vim mode
1974 287 : if (this.statusLine) {
1975 288 : if (this.vimMode) {
1976 289 : this.statusLine.show();
1977 290 : this.statusLine.updateMode('normal');
1978 291 : } else {
1979 292 : this.statusLine.hide();
1980 293 : }
1981 294 : }
1982 295 :
1983 296 : // Notify parent of vim mode change for persistence
1984 297 : if (this.onVimModeChange) {
1985 298 : this.onVimModeChange(this.vimMode);
1986 299 : }
1987 300 : }
1988 301 :
1989 302 : /**
1990 303 : * Handle cursor position changes.
1991 304 : */
1992 305 : handleSelectionChange(line, col) {
1993 306 : if (this.statusLine) {
1994 307 : this.statusLine.updatePosition(line, col);
1995 308 : }
1996 309 : }
1997 310 :
1998 311 : /**
1999 312 : * Handle vim mode state changes (normal, insert, visual, etc.)
2000 313 : */
2001 314 : handleVimModeUpdate(mode) {
2002 315 : if (this.statusLine && this.vimMode) {
2003 316 : this.statusLine.updateMode(mode);
2004 317 : }
2005 318 278: }
2006 319 279:
2007 320 280: jumpToHeader(header) {
2008 ...
2009 448 408: * Set vim mode.
2010 449 409: */
2011 450 410: setVimMode(enabled) {
2012 451 411: this.updateVimModeState(vimMode = enabled;
2013 412: this.vimCheckbox.checked = enabled;
2014 413: if (this.cmEditor) {
2015 451 414: CodeMirror.setVimMode(this.cmEditor, enabled);
2016 415: }
2017 452 416: }
2018 453 417:
2019 454 418: /**
2020 ...
2021 483 447: this.cmEditor = null;
2022 484 448: }
2023 485 449:
2024 486 : this.statusLine?.destroy();
2025 487 450: this.outlineSidebar?.destroy();
2026 488 451: this.previewSidebar?.destroy();
2027 489 452: this.wrapper.remove();
2028 ...
2029Modified regular file extensions/editor/home.js:
2030 ...
2031 17 17: // Editor layout instance
2032 18 18: let editorLayout = null;
2033 19 19:
2034 20 20: // Settings keystore for vim mode preference
2035 21: let settingsStore = null;
2036 21 22: const SETTINGS_KEY = 'editor.vimMode';
2037 22 23:
2038 23 24: /**
2039 24 : * Sample markdown content for testing folding features.
2040 25 : * Tests: headers (6 levels), nested lists, code blocks.
2041 25: * Sample markdown content for new documents
2042 26 26: */
2043 27 : const SAMPLE_CONTENT = `# Level 1 Header - Main Document
2044 28 :
2045 29 : This content is under a level 1 header.
2046 30 :
2047 31 : ## Level 2 - Features
2048 32 :
2049 33 : Content under level 2.
2050 34 :
2051 35 : ### Level 3 - Folding Types
2052 36 :
2053 37 : We support multiple folding types.
2054 38 :
2055 39 : #### Level 4 - Header Folding
2056 40 :
2057 41 : Headers fold everything until the next header of same or higher level.
2058 42 :
2059 43 : ##### Level 5 - Deep Nesting
2060 44 :
2061 45 : This is deeply nested content.
2062 46 :
2063 47 : ###### Level 6 - Maximum Depth
2064 48 :
2065 49 : This is the deepest header level supported.
2066 50 :
2067 51 : Back to level 5 content.
2068 52 :
2069 53 : ##### Level 5 - Another Section
2070 54 :
2071 55 : Another level 5 section.
2072 56 :
2073 57 : #### Level 4 - List Folding
2074 58 :
2075 59 : Lists with children are foldable:
2076 60 :
2077 61 : - Parent item with children
2078 62 : - Child item one
2079 63 : - Child item two
2080 64 : - Grandchild item
2081 65 : - Another grandchild
2082 66 : - Child item three
2083 67 : - Simple item (no children)
2084 68 : - Another parent
2085 69 : - Single child
2086 70 :
2087 71 : Numbered lists also fold:
2088 72 :
2089 73 : 1. First parent
2090 74 : 1. Sub-item one
2091 75 : 2. Sub-item two
2092 76 : 2. Second parent
2093 77 : - Mixed child
2094 78 : - Another mixed
2095 79 :
2096 80 : #### Level 4 - Code Block Folding
2097 81 :
2098 82 : Top-level code blocks fold their contents:
2099 27: const SAMPLE_CONTENT = `# Welcome to the Editor
2100 28:
2101 29: This is a **markdown editor** with live preview and outline navigation.
2102 30:
2103 31: ## Features
2104 32:
2105 33: - **Outline sidebar** - Click headers to jump to them
2106 34: - **Live preview** - See rendered markdown as you type
2107 35: - **Vim mode** - Toggle vim keybindings in the toolbar
2108 36: - **Focus mode** - Distraction-free editing
2109 37:
2110 38: ## Getting Started
2111 39:
2112 40: Start typing to edit this document. Use the toolbar buttons to toggle sidebars.
2113 41:
2114 42: ### Keyboard Shortcuts
2115 43:
2116 44: - \`Cmd+Shift+O\` - Toggle outline sidebar
2117 45: - \`Cmd+Shift+P\` - Toggle preview sidebar
2118 46: - \`Escape\` - Exit focus mode
2119 47:
2120 48: ## Code Example
2121 83 49:
2122 84 50: \`\`\`javascript
2123 85 51: function examplegreet(name) {
2124 86 52: return if (condition) {
2125 87 : doSomething();
2126 88 : }
2127 89 : return result;
2128 90 : }
2129 91 :
2130 92 : class MyClass {
2131 93 : constructor() {
2132 94 : this.x = 1;
2133 95 : }
2134 96 :
2135 97 : method() {
2136 98 : return this.x;
2137 99 52: }\`Hello, \${name}!\`;
2138 100 53: }
2139 101 54: \`\`\`
2140 102 55:
2141 103 : ### Level 3 - Vim Fold Commands
2142 104 :
2143 105 : Test these vim commands (enable vim mode first):
2144 106 :
2145 107 : | Command | Action |
2146 108 : |---------|--------|
2147 109 : | \`za\` | Toggle fold under cursor |
2148 110 : | \`zo\` | Open fold under cursor |
2149 111 : | \`zc\` | Close fold under cursor |
2150 112 : | \`zR\` | Open all folds |
2151 113 : | \`zM\` | Close all folds |
2152 114 : | \`zr\` | Reduce folding (open one level) |
2153 115 : | \`zm\` | More folding (close one level) |
2154 116 :
2155 117 : ## Level 2 - Another Top Section
2156 118 :
2157 119 : This tests that level 2 properly ends the previous level 2 section.
2158 120 :
2159 121 : ### Level 3 - Final Nested
2160 122 :
2161 123 : Final nested content.
2162 124 :
2163 125 : ## Level 2 - Conclusion
2164 126 :
2165 127 : End of test document.
2166 56: ## Lists
2167 57:
2168 58: - Item one
2169 59: - Item two
2170 60: - Item three
2171 61:
2172 62: 1. First
2173 63: 2. Second
2174 64: 3. Third
2175 65:
2176 66: ## Links and Images
2177 67:
2178 68: [Visit GitHub](https://github.com)
2179 69:
2180 70: > This is a blockquote.
2181 71: > It can span multiple lines.
2182 72:
2183 73: ---
2184 74:
2185 75: *Happy writing!*
2186 128 76: `;
2187 129 77:
2188 130 78: /**
2189 ...
2190 139 87: return;
2191 140 88: }
2192 141 89:
2193 142 90: // Load vim mode preference from localStorage
2194 143 91: let vimMode = false;
2195 144 : try {
2196 145 : vimMode = localStorage.getItem(SETTINGS_KEY) === 'true';
2197 146 : debug && console.log('[editor] Loaded vimMode setting:', vimMode);
2198 147 : } catch (err) {
2199 148 : debug && console.log('[editor] Failed to load settings:', err);
2200 92: if (api?.utils?.createDatastoreStore) {
2201 93: try {
2202 94: settingsStore = await api.utils.createDatastoreStore('editor', { vimMode: false });
2203 95: vimMode = settingsStore.get(SETTINGS_KEY) || false;
2204 96: debug && console.log('[editor] Loaded vimMode setting:', vimMode);
2205 97: } catch (err) {
2206 98: debug && console.log('[editor] Failed to load settings:', err);
2207 99: }
2208 149 100: }
2209 150 101:
2210 151 102: // Check URL params for content or file path
2211 ...
2212 178 129: initialContent,
2213 179 130: vimMode,
2214 180 131: onContentChange: handleContentChange,
2215 181 : onVimModeChange: handleVimModeChange,
2216 182 132: });
2217 183 133:
2218 184 134: // Set up escape handler
2219 ...
2220 192 142: });
2221 193 143: }
2222 194 144:
2223 145: // Listen for vim mode changes to persist
2224 146: const vimCheckbox = document.querySelector('.vim-toggle input');
2225 147: if (vimCheckbox) {
2226 148: vimCheckbox.addEventListener('change', async () => {
2227 149: const enabled = vimCheckbox.checked;
2228 150: if (settingsStore) {
2229 151: try {
2230 152: await settingsStore.set(SETTINGS_KEY, enabled);
2231 153: debug && console.log('[editor] Saved vimMode setting:', enabled);
2232 154: } catch (err) {
2233 155: debug && console.log('[editor] Failed to save vimMode setting:', err);
2234 156: }
2235 157: }
2236 158: });
2237 159: }
2238 160:
2239 195 161: debug && console.log('[editor] Editor initialized');
2240 196 162: };
2241 197 163:
2242 ...
2243 206 172: };
2244 207 173:
2245 208 174: /**
2246 209 : * Handle vim mode changes - persist to localStorage
2247 210 : */
2248 211 : const handleVimModeChange = (enabled) => {
2249 212 : try {
2250 213 : localStorage.setItem(SETTINGS_KEY, enabled ? 'true' : 'false');
2251 214 : console.log('[editor] Saved vimMode setting:', enabled);
2252 215 : } catch (err) {
2253 216 : console.error('[editor] Failed to save vimMode setting:', err);
2254 217 : }
2255 218 : };
2256 219 :
2257 220 : /**
2258 221 175: * Clean up on unload
2259 222 176: */
2260 223 177: const cleanup = () => {
2261 ...
2262Removed regular file extensions/editor/status-line.js:
2263 1 : /**
2264 2 : * Status Line - Vim-style status bar for CodeMirror editor.
2265 3 : *
2266 4 : * Displays:
2267 5 : * - Mode indicator (NORMAL, INSERT, VISUAL, V-LINE)
2268 6 : * - Cursor position (Ln X, Col Y)
2269 7 : * - Temporary messages
2270 8 : */
2271 9 :
2272 10 : export class StatusLine {
2273 11 : constructor(options = {}) {
2274 12 : this.container = options.container;
2275 13 : this.currentMode = 'normal';
2276 14 : this.messageTimeout = null;
2277 15 : this.originalModeText = '';
2278 16 :
2279 17 : this.init();
2280 18 : }
2281 19 :
2282 20 : init() {
2283 21 : // Create status line container
2284 22 : this.element = document.createElement('div');
2285 23 : this.element.className = 'vim-status-line';
2286 24 : this.element.style.cssText = `
2287 25 : display: flex;
2288 26 : justify-content: space-between;
2289 27 : align-items: center;
2290 28 : padding: 4px 12px;
2291 29 : background: var(--base00);
2292 30 : border-top: 1px solid var(--base02);
2293 31 : min-height: 22px;
2294 32 : font-family: var(--theme-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);
2295 33 : font-size: 12px;
2296 34 : `;
2297 35 :
2298 36 : // Mode indicator (left side)
2299 37 : this.modeIndicator = document.createElement('span');
2300 38 : this.modeIndicator.className = 'vim-mode-indicator';
2301 39 : this.modeIndicator.style.cssText = `
2302 40 : color: var(--base0A);
2303 41 : font-weight: 600;
2304 42 : `;
2305 43 : this.modeIndicator.textContent = 'NORMAL';
2306 44 :
2307 45 : // Position info (right side)
2308 46 : this.positionInfo = document.createElement('span');
2309 47 : this.positionInfo.className = 'vim-position-info';
2310 48 : this.positionInfo.style.cssText = `
2311 49 : color: var(--base04);
2312 50 : `;
2313 51 : this.positionInfo.textContent = 'Ln 1, Col 1';
2314 52 :
2315 53 : this.element.appendChild(this.modeIndicator);
2316 54 : this.element.appendChild(this.positionInfo);
2317 55 :
2318 56 : if (this.container) {
2319 57 : this.container.appendChild(this.element);
2320 58 : }
2321 59 : }
2322 60 :
2323 61 : /**
2324 62 : * Update the mode display.
2325 63 : * @param {string} mode - The vim mode ('normal', 'insert', 'visual', 'visual-line', 'replace')
2326 64 : */
2327 65 : updateMode(mode) {
2328 66 : this.currentMode = mode;
2329 67 :
2330 68 : const modeLabels = {
2331 69 : 'normal': 'NORMAL',
2332 70 : 'insert': '-- INSERT --',
2333 71 : 'visual': '-- VISUAL --',
2334 72 : 'visual-line': '-- V-LINE --',
2335 73 : 'visual-block': '-- V-BLOCK --',
2336 74 : 'replace': '-- REPLACE --',
2337 75 : };
2338 76 :
2339 77 : const modeColors = {
2340 78 : 'normal': 'var(--base0A)', // Yellow
2341 79 : 'insert': 'var(--base0B)', // Green
2342 80 : 'visual': 'var(--base0E)', // Purple
2343 81 : 'visual-line': 'var(--base0E)', // Purple
2344 82 : 'visual-block': 'var(--base0E)', // Purple
2345 83 : 'replace': 'var(--base08)', // Red
2346 84 : };
2347 85 :
2348 86 : const label = modeLabels[mode] || mode.toUpperCase();
2349 87 : const color = modeColors[mode] || 'var(--base05)';
2350 88 :
2351 89 : this.modeIndicator.textContent = label;
2352 90 : this.modeIndicator.style.color = color;
2353 91 : this.originalModeText = label;
2354 92 : }
2355 93 :
2356 94 : /**
2357 95 : * Update the cursor position display.
2358 96 : * @param {number} line - Current line number (1-indexed)
2359 97 : * @param {number} col - Current column number (1-indexed)
2360 98 : */
2361 99 : updatePosition(line, col) {
2362 100 : this.positionInfo.textContent = `Ln ${line}, Col ${col}`;
2363 101 : }
2364 102 :
2365 103 : /**
2366 104 : * Show a temporary message in place of the mode indicator.
2367 105 : * @param {string} message - Message to display
2368 106 : * @param {number} duration - Duration in ms (0 = permanent until next update)
2369 107 : */
2370 108 : showMessage(message, duration = 3000) {
2371 109 : // Clear any existing timeout
2372 110 : if (this.messageTimeout) {
2373 111 : clearTimeout(this.messageTimeout);
2374 112 : this.messageTimeout = null;
2375 113 : }
2376 114 :
2377 115 : // Store original and show message
2378 116 : const originalText = this.modeIndicator.textContent;
2379 117 : const originalColor = this.modeIndicator.style.color;
2380 118 :
2381 119 : this.modeIndicator.textContent = message;
2382 120 : this.modeIndicator.style.color = 'var(--base04)';
2383 121 :
2384 122 : if (duration > 0) {
2385 123 : this.messageTimeout = setTimeout(() => {
2386 124 : this.modeIndicator.textContent = this.originalModeText || originalText;
2387 125 : this.modeIndicator.style.color = originalColor;
2388 126 : this.messageTimeout = null;
2389 127 : }, duration);
2390 128 : }
2391 129 : }
2392 130 :
2393 131 : /**
2394 132 : * Show the status line.
2395 133 : */
2396 134 : show() {
2397 135 : this.element.style.display = 'flex';
2398 136 : }
2399 137 :
2400 138 : /**
2401 139 : * Hide the status line.
2402 140 : */
2403 141 : hide() {
2404 142 : this.element.style.display = 'none';
2405 143 : }
2406 144 :
2407 145 : /**
2408 146 : * Get the status line element.
2409 147 : */
2410 148 : getElement() {
2411 149 : return this.element;
2412 150 : }
2413 151 :
2414 152 : /**
2415 153 : * Destroy the status line.
2416 154 : */
2417 155 : destroy() {
2418 156 : if (this.messageTimeout) {
2419 157 : clearTimeout(this.messageTimeout);
2420 158 : }
2421 159 : this.element.remove();
2422 160 : }
2423 161 : }
2424Removed regular file notes/izui-model.md:
2425 1 : # IZUI: Inverted Zooming User Interface
2426 2 :
2427 3 : Research note documenting the IZUI navigation model for Peek.
2428 4 :
2429 5 : ## Overview
2430 6 :
2431 7 : IZUI (Inverted Zooming User Interface) inverts the traditional ZUI model:
2432 8 :
2433 9 : | ZUI | IZUI |
2434 10 : |-----|------|
2435 11 : | Start from known root | Enter at any point |
2436 12 : | Navigate by zooming in | Navigate by zooming out (ESC) |
2437 13 : | Single entry point | Multiple entry points |
2438 14 : | Navigate forward to destination | Navigate backward to familiar ground |
2439 15 :
2440 16 : ## Core Principles
2441 17 :
2442 18 : 1. **Unbounded Entry**: Users can enter at any point via global hotkeys, commands, links, or extension actions.
2443 19 :
2444 20 : 2. **Predictable Exit**: ESC always walks backward through navigation until reaching the entry point.
2445 21 :
2446 22 : 3. **Context Preservation**: Each window preserves its internal state for return visits.
2447 23 :
2448 24 : ## Implementation Status
2449 25 :
2450 26 : ### Current: Minimal Parent Tracking (v1)
2451 27 :
2452 28 : The initial implementation uses simple parent-child relationships:
2453 29 : - Windows track their opener via `source` address
2454 30 : - On ESC/close, focus restores to parent window
2455 31 : - No central stack data structure needed
2456 32 :
2457 33 : See `app/izui.js` for implementation.
2458 34 :
2459 35 : ### Future: Full Stack Model (v2)
2460 36 :
2461 37 : For more complex navigation patterns, a full stack model may be needed.
2462 38 :
2463 39 : #### Stack Structure
2464 40 :
2465 41 : ```
2466 42 : IzuiStack {
2467 43 : id: string // Unique stack identifier
2468 44 : entries: StackEntry[] // Ordered list, index 0 = oldest
2469 45 : entryMode: 'active' | 'transient'
2470 46 : createdAt: number
2471 47 : }
2472 48 :
2473 49 : StackEntry {
2474 50 : windowId: number
2475 51 : address: string
2476 52 : params: object
2477 53 : pushedAt: number
2478 54 : }
2479 55 : ```
2480 56 :
2481 57 : #### Entry Modes
2482 58 :
2483 59 : **Active Mode**: Peek was already focused when stack was created.
2484 60 : - ESC navigates internal state before closing
2485 61 : - Designed for deep navigation workflows
2486 62 :
2487 63 : **Transient Mode**: Peek was invoked from another app via global hotkey.
2488 64 : - ESC closes immediately to return user to previous app
2489 65 : - Minimal friction for quick peek-and-return workflows
2490 66 :
2491 67 : #### Stack Operations
2492 68 :
2493 69 : - **Push**: Window opened from current context
2494 70 : - **Pop**: ESC pressed, window at root state
2495 71 : - **Destroy**: Last entry popped
2496 72 :
2497 73 : ## Implementation Options Evaluated
2498 74 :
2499 75 : ### Option A: Client-side only (pubsub coordination)
2500 76 :
2501 77 : Stack state in each renderer, coordinated via pubsub.
2502 78 :
2503 79 : **Pros**: Backend-agnostic
2504 80 : **Cons**: Eventually consistent, race conditions, lost state if coordinator closes
2505 81 :
2506 82 : ### Option B: Background window coordinator
2507 83 :
2508 84 : Stack state in `peek://app/background.html`, queried via pubsub.
2509 85 :
2510 86 : **Pros**: Single source of truth, backend-agnostic
2511 87 : **Cons**: Latency on every query, complex message passing
2512 88 :
2513 89 : ### Option C: Duplicate backend implementation
2514 90 :
2515 91 : Implement in both Electron (TypeScript) and Tauri (Rust).
2516 92 :
2517 93 : **Pros**: Synchronous, no race conditions
2518 94 : **Cons**: Duplicate code, maintenance burden
2519 95 :
2520 96 : ### Option D: Minimal parent tracking (SELECTED)
2521 97 :
2522 98 : Use existing `source` tracking, focus parent on close.
2523 99 :
2524 100 : **Pros**: Simple, backend-agnostic, no new data structures
2525 101 : **Cons**: Limited to linear parent-child, no branching stacks
2526 102 :
2527 103 : ## Security Considerations
2528 104 :
2529 105 : ### Cross-Origin Navigation
2530 106 :
2531 107 : | From | To | Behavior |
2532 108 : |------|----|----------|
2533 109 : | peek://app/* | peek://app/* | Same stack |
2534 110 : | peek://app/* | peek://ext/* | Same stack |
2535 111 : | peek://ext/* | peek://app/* | Same stack |
2536 112 : | peek://* | https://* | New stack (isolated) |
2537 113 : | https://* | peek://* | Blocked |
2538 114 : | https://* | https://* | New stack (isolated) |
2539 115 :
2540 116 : External URLs should start isolated stacks to prevent untrusted content from manipulating Peek's navigation.
2541 117 :
2542 118 : ## History Integration (Future)
2543 119 :
2544 120 : Stack operations could optionally record to history:
2545 121 :
2546 122 : ```javascript
2547 123 : trackNavigation(address, {
2548 124 : source: 'izui',
2549 125 : stackId: stack.id,
2550 126 : action: 'push' | 'pop'
2551 127 : });
2552 128 : ```
2553 129 :
2554 130 : ## Visual Indicators (Future)
2555 131 :
2556 132 : Possible UI for stack depth:
2557 133 : - Breadcrumb trail
2558 134 : - Depth counter badge
2559 135 : - Mini-map of stack
2560 136 :
2561 137 : ## Open Questions
2562 138 :
2563 139 : 1. **Stack Branching**: Should stacks support multiple children from one parent?
2564 140 : 2. **Stack Persistence**: Should stacks survive app restart for session restore?
2565 141 : 3. **Multiple Stacks**: How should concurrent stacks interact?
2566 142 :
2567 143 : ## References
2568 144 :
2569 145 : - `notes/escape-navigation.md` - ESC behavior design
2570 146 : - `notes/design-vision.md` - Original IZUI concept
2571 147 : - `app/izui.js` - Current implementation
2572Modified regular file package.json:
2573 ...
2574 108 108: "test:e2e:server": "./scripts/e2e-server.sh",
2575 109 109: "interactive-test:e2e:full-sync": "./scripts/e2e-full-sync-test.sh",
2576 110 110: "interactive-test:e2e:full-sync:auto": "./scripts/e2e-full-sync-test.sh --headless --build",
2577 111: "test:e2e:mobile": "LOCAL_IP=localhost ./scripts/e2e-full-sync-test.sh --headless --build",
2578 111 112: "test:mobile": "cd backend/tauri-mobile && npm test",
2579 112 113: "test:extension": "node --test backend/extension/tests/*.test.js",
2580 113 114: "extension:chrome": "backend/extension/scripts/launch-chrome.sh",
2581 ...
2582Modified regular file playwright.config.ts:
2583 ...
2584 31 31: browserName: 'chromium',
2585 32 32: },
2586 33 33: },
2587 34 : {
2588 35 : name: 'editor',
2589 36 : testMatch: /editor\/.*\.spec\.ts/,
2590 37 : use: {
2591 38 : // Editor tests run in browser
2592 39 : browserName: 'chromium',
2593 40 : },
2594 41 : },
2595 42 34: // Future projects:
2596 43 35: // { name: 'mobile', testMatch: /mobile\/.*\.spec\.ts/ },
2597 44 36: // { name: 'extension', testMatch: /extension\/.*\.spec\.ts/ },
2598 ...
2599Modified regular file preload.js:
2600 ...
2601 454 454: // History operations (visits joined with addresses)
2602 455 455: getHistory: (filter = {}) => {
2603 456 456: return ipcRenderer.invoke('datastore-get-history', { filter });
2604 457 : },
2605 458 :
2606 459 : // Item visit operations (modern URL history API)
2607 460 : queryItemVisits: (filter = {}) => {
2608 461 : return ipcRenderer.invoke('datastore-query-item-visits', { filter });
2609 462 : },
2610 463 : getItemVisits: (itemId, filter = {}) => {
2611 464 : return ipcRenderer.invoke('datastore-get-item-visits', { itemId, filter });
2612 465 : },
2613 466 : recordItemVisit: (itemId, options = {}) => {
2614 467 : return ipcRenderer.invoke('datastore-record-item-visit', { itemId, options });
2615 468 457: }
2616 469 458: };
2617 470 459:
2618 ...
2619Modified regular file schema/generated/sqlite-full.sql:
2620 1 1: -- Generated by schema/codegen.js
2621 2 2: -- Schema version: 1
2622 3 : -- Generated: 2026-01-30T11:28:43.966Z
2623 3: -- Generated: 2026-01-31T00:08:16.086Z
2624 4 4: -- DO NOT EDIT - regenerate with: yarn schema:codegen
2625 5 5:
2626 6 6: -- Unified content storage - URLs, text notes, tagsets, and images
2627 ...
2628Modified regular file schema/generated/sqlite-sync.sql:
2629 1 1: -- Generated by schema/codegen.js
2630 2 2: -- Schema version: 1
2631 3 : -- Generated: 2026-01-30T11:28:43.966Z
2632 3: -- Generated: 2026-01-31T00:08:16.091Z
2633 4 4: -- DO NOT EDIT - regenerate with: yarn schema:codegen
2634 5 5:
2635 6 6: -- Unified content storage - URLs, text notes, tagsets, and images
2636 ...
2637Modified regular file schema/generated/types.rs:
2638 1 1: // Generated by schema/codegen.js
2639 2 2: // Schema version: 1
2640 3 : // Generated: 2026-01-30T11:28:43.967Z
2641 3: // Generated: 2026-01-31T00:08:16.091Z
2642 4 4: // DO NOT EDIT - regenerate with: yarn schema:codegen
2643 5 5:
2644 6 6: use serde::{Deserialize, Serialize};
2645 ...
2646Modified regular file schema/generated/types.ts:
2647 1 1: /**
2648 2 2: * Generated by schema/codegen.js
2649 3 3: * Schema version: 1
2650 4 : * Generated: 2026-01-30T11:28:43.966Z
2651 4: * Generated: 2026-01-31T00:08:16.091Z
2652 5 5: * DO NOT EDIT - regenerate with: yarn schema:codegen
2653 6 6: */
2654 7 7:
2655 ...
2656Modified executable file scripts/e2e-full-sync-test.sh:
2657 ...
2658 412 412: );
2659 413 413:
2660 414 414: CREATE TABLE IF NOT EXISTS tags (
2661 415 415: id INTEGERTEXT PRIMARY KEY AUTOINCREMENT,
2662 416 416: name TEXT NOT NULL UNIQUE,
2663 417 417: frequency INTEGER NOT NULL DEFAULT 0,
2664 418 418: lastUsed TEXT NOT NULL,
2665 ...
2666 423 423:
2667 424 424: CREATE TABLE IF NOT EXISTS item_tags (
2668 425 425: item_id TEXT NOT NULL,
2669 426 426: tag_id INTEGERTEXT NOT NULL,
2670 427 427: created_at TEXT NOT NULL,
2671 428 428: PRIMARY KEY (item_id, tag_id),
2672 429 429: FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
2673 ...
2674 462 462: ('ios-e2e-url-1', 'url', 'https://example.com/ios-origin-1', '', '', '', datetime('now'), datetime('now')),
2675 463 463: ('ios-e2e-note-1', 'text', '', 'Note created on iOS', '', '', datetime('now'), datetime('now'));
2676 464 464:
2677 465 465: -- Tags (TEXT IDs matching desktop/server format: tag_{timestamp}_{random9})
2678 466 466: INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt)
2679 467 467: VALUES
2680 468 468: ('tag_e2e_ios_001', 'ios', 1, datetime('now'), 1.0, datetime('now'), datetime('now')),
2681 469 469: ('tag_e2e_ios_002', 'e2e', 1, datetime('now'), 1.0, datetime('now'), datetime('now')),
2682 470 470: ('tag_e2e_ios_003', 'note', 1, datetime('now'), 1.0, datetime('now'), datetime('now'));
2683 471 471:
2684 472 472: -- Tag associations (using TEXT tag_id)
2685 473 473: INSERT INTO item_tags (item_id, tag_id, created_at) VALUES ('ios-e2e-url-1', 1'tag_e2e_ios_001', datetime('now'));
2686 474 474: INSERT INTO item_tags (item_id, tag_id, created_at) VALUES ('ios-e2e-url-1', 2'tag_e2e_ios_002', datetime('now'));
2687 475 475: INSERT INTO item_tags (item_id, tag_id, created_at) VALUES ('ios-e2e-note-1', 1'tag_e2e_ios_001', datetime('now'));
2688 476 476: INSERT INTO item_tags (item_id, tag_id, created_at) VALUES ('ios-e2e-note-1', 3'tag_e2e_ios_003', datetime('now'));
2689 477 477: SQLEOF
2690 478 478:
2691 479 479: IOS_COUNT=$(sqlite3 "$IOS_DB" "SELECT COUNT(*) FROM items WHERE deleted_at IS NULL;")
2692 ...
2693 765 765: VALUES
2694 766 766: ('ios-crossdev-1', 'url', '$CROSS_URL', '', '', '', datetime('now'), datetime('now'));
2695 767 767:
2696 768 768: INSERT OR IGNORE INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt)
2697 769 769: VALUES ('tag_e2e_crossdev', 'cross-device', 1, datetime('now'), 1.0, datetime('now'), datetime('now'));
2698 770 770:
2699 771 771: INSERT INTO item_tags (item_id, tag_id, created_at)
2700 772 : SELECT 'ios-crossdev-1', id, datetime('now') FROM tags WHERE name = 'cross-device';
2701 772: VALUES ('ios-crossdev-1', 'tag_e2e_crossdev', datetime('now'));
2702 773 773: SQLEOF
2703 774 774:
2704 775 775: echo " Seeded cross-device URL into iOS"
2705 ...
2706 808 808: INSERT INTO items (id, type, url, content, metadata, sync_source, created_at, updated_at)
2707 809 809: VALUES ('ios-tagset-1', 'tagset', '', '', '', '', datetime('now'), datetime('now'));
2708 810 810:
2709 811 811: INSERT OR IGNORE INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt)
2710 812 812: VALUES
2711 813 813: ('tag_e2e_shared', 'shared', 1, datetime('now'), 1.0, datetime('now'), datetime('now')),
2712 814 814: ('tag_e2e_tagset', 'tagset', 1, datetime('now'), 1.0, datetime('now'), datetime('now'));
2713 815 815:
2714 816 816: INSERT INTO item_tags (item_id, tag_id, created_at)
2715 817 : SELECT 'ios-tagset-1', id, datetime('now') FROM tags WHERE name = 'shared';
2716 817: VALUES ('ios-tagset-1', 'tag_e2e_shared', datetime('now'));
2717 818 818: INSERT INTO item_tags (item_id, tag_id, created_at)
2718 819 : SELECT 'ios-tagset-1', id, datetime('now') FROM tags WHERE name = 'tagset';
2719 819: VALUES ('ios-tagset-1', 'tag_e2e_tagset', datetime('now'));
2720 820 820: SQLEOF
2721 821 821:
2722 822 822: echo " Seeded cross-device tagset into iOS"
2723 ...
27241100 1100: VALUES ('dup-ios-tagset-1', 'tagset', '', '', '', '', datetime('now'), datetime('now', '-1 day'));
27251101 1101:
27261102 1102: INSERT INTO item_tags (item_id, tag_id, created_at)
27271103 : SELECT 'dup-ios-tagset-1', id, datetime('now') FROM tags WHERE name IN ('shared', 'tagset');
2728 1103: VALUES ('dup-ios-tagset-1', 'tag_e2e_shared', datetime('now'));
2729 1104: INSERT INTO item_tags (item_id, tag_id, created_at)
2730 1105: VALUES ('dup-ios-tagset-1', 'tag_e2e_tagset', datetime('now'));
27311104 1106: SQLEOF
27321105 1107: echo " Seeded 3 duplicates into iOS DB"
27331106 1108:
2734 ...
2735Removed regular file tests/editor/editor-folding.spec.ts:
2736 1 : /**
2737 2 : * Editor Folding Tests
2738 3 : *
2739 4 : * Tests for CodeMirror markdown folding features (folditall-style).
2740 5 : * Tests actual folding BEHAVIOR, not just that commands run without errors.
2741 6 : *
2742 7 : * Run with:
2743 8 : * npx playwright test tests/editor/ --project=editor
2744 9 : */
2745 10 :
2746 11 : import { test, expect, Page } from '@playwright/test';
2747 12 : import path from 'path';
2748 13 : import { fileURLToPath } from 'url';
2749 14 : import { createServer } from 'http';
2750 15 : import { readFileSync, existsSync } from 'fs';
2751 16 :
2752 17 : const __filename = fileURLToPath(import.meta.url);
2753 18 : const __dirname = path.dirname(__filename);
2754 19 : const ROOT = path.join(__dirname, '../..');
2755 20 :
2756 21 : // Simple static file server
2757 22 : let server: ReturnType<typeof createServer>;
2758 23 : let serverUrl: string;
2759 24 :
2760 25 : const mimeTypes: Record<string, string> = {
2761 26 : '.html': 'text/html',
2762 27 : '.js': 'application/javascript',
2763 28 : '.css': 'text/css',
2764 29 : '.json': 'application/json',
2765 30 : };
2766 31 :
2767 32 : function startServer(): Promise<string> {
2768 33 : return new Promise((resolve) => {
2769 34 : server = createServer((req, res) => {
2770 35 : let filePath = path.join(ROOT, req.url || '/');
2771 36 :
2772 37 : if (req.url === '/' || req.url === '/test') {
2773 38 : filePath = path.join(__dirname, 'test-page.html');
2774 39 : }
2775 40 :
2776 41 : if (req.url?.startsWith('/node_modules/')) {
2777 42 : filePath = path.join(ROOT, req.url);
2778 43 : }
2779 44 :
2780 45 : const ext = path.extname(filePath);
2781 46 : const contentType = mimeTypes[ext] || 'text/plain';
2782 47 :
2783 48 : try {
2784 49 : if (existsSync(filePath)) {
2785 50 : const content = readFileSync(filePath);
2786 51 : res.writeHead(200, { 'Content-Type': contentType });
2787 52 : res.end(content);
2788 53 : } else {
2789 54 : res.writeHead(404);
2790 55 : res.end(`Not found: ${filePath}`);
2791 56 : }
2792 57 : } catch (err) {
2793 58 : res.writeHead(500);
2794 59 : res.end(`Error: ${err}`);
2795 60 : }
2796 61 : });
2797 62 :
2798 63 : server.listen(0, '127.0.0.1', () => {
2799 64 : const addr = server.address();
2800 65 : if (addr && typeof addr === 'object') {
2801 66 : const url = `http://127.0.0.1:${addr.port}`;
2802 67 : resolve(url);
2803 68 : }
2804 69 : });
2805 70 : });
2806 71 : }
2807 72 :
2808 73 : function stopServer() {
2809 74 : if (server) {
2810 75 : server.close();
2811 76 : }
2812 77 : }
2813 78 :
2814 79 : test.describe('Editor Folding @editor', () => {
2815 80 : let page: Page;
2816 81 :
2817 82 : test.beforeAll(async ({ browser }) => {
2818 83 : serverUrl = await startServer();
2819 84 : page = await browser.newPage();
2820 85 :
2821 86 : page.on('console', msg => {
2822 87 : if (msg.type() === 'error') {
2823 88 : console.log('Page error:', msg.text());
2824 89 : }
2825 90 : });
2826 91 :
2827 92 : page.on('pageerror', err => {
2828 93 : console.log('Page exception:', err.message);
2829 94 : });
2830 95 :
2831 96 : await page.goto(`${serverUrl}/test`);
2832 97 : await page.waitForSelector('body[data-ready="true"]', { timeout: 15000 });
2833 98 : });
2834 99 :
2835 100 : test.afterAll(async () => {
2836 101 : await page.close();
2837 102 : stopServer();
2838 103 : });
2839 104 :
2840 105 : // ==========================================================================
2841 106 : // Helper: Count fold placeholders (indicates folded content)
2842 107 : // ==========================================================================
2843 108 :
2844 109 : async function getFoldPlaceholderCount(): Promise<number> {
2845 110 : return await page.evaluate(() => {
2846 111 : return document.querySelectorAll('.cm-foldPlaceholder').length;
2847 112 : });
2848 113 : }
2849 114 :
2850 115 : async function getVisibleLineCount(): Promise<number> {
2851 116 : return await page.evaluate(() => {
2852 117 : // Count visible line elements in the editor
2853 118 : const lines = document.querySelectorAll('.cm-line');
2854 119 : return lines.length;
2855 120 : });
2856 121 : }
2857 122 :
2858 123 : // ==========================================================================
2859 124 : // Basic Editor Setup
2860 125 : // ==========================================================================
2861 126 :
2862 127 : test.describe('Editor Setup', () => {
2863 128 : test('editor is initialized with content', async () => {
2864 129 : const content = await page.evaluate(() => {
2865 130 : return window.editorView?.state.doc.toString();
2866 131 : });
2867 132 : expect(content).toContain('# Level 1 Header');
2868 133 : expect(content).toContain('###### Level 6');
2869 134 : });
2870 135 :
2871 136 : test('fold gutter is visible', async () => {
2872 137 : const hasFoldGutter = await page.evaluate(() => {
2873 138 : return !!document.querySelector('.cm-foldGutter');
2874 139 : });
2875 140 : expect(hasFoldGutter).toBe(true);
2876 141 : });
2877 142 :
2878 143 : test('no folds initially (no placeholders)', async () => {
2879 144 : await page.evaluate(() => window.unfoldAll());
2880 145 : await page.waitForTimeout(50);
2881 146 : const count = await getFoldPlaceholderCount();
2882 147 : expect(count).toBe(0);
2883 148 : });
2884 149 : });
2885 150 :
2886 151 : // ==========================================================================
2887 152 : // foldAll / unfoldAll API Functions
2888 153 : // ==========================================================================
2889 154 :
2890 155 : test.describe('Fold All / Unfold All API', () => {
2891 156 : test.beforeEach(async () => {
2892 157 : await page.evaluate(() => window.unfoldAll());
2893 158 : await page.waitForTimeout(50);
2894 159 : });
2895 160 :
2896 161 : test('foldAll creates fold placeholders', async () => {
2897 162 : const beforeCount = await getFoldPlaceholderCount();
2898 163 : expect(beforeCount).toBe(0);
2899 164 :
2900 165 : await page.evaluate(() => window.foldAll());
2901 166 : await page.waitForTimeout(100);
2902 167 :
2903 168 : const afterCount = await getFoldPlaceholderCount();
2904 169 : expect(afterCount).toBeGreaterThan(0);
2905 170 : });
2906 171 :
2907 172 : test('unfoldAll removes all fold placeholders', async () => {
2908 173 : // First fold all
2909 174 : await page.evaluate(() => window.foldAll());
2910 175 : await page.waitForTimeout(100);
2911 176 :
2912 177 : const foldedCount = await getFoldPlaceholderCount();
2913 178 : expect(foldedCount).toBeGreaterThan(0);
2914 179 :
2915 180 : // Then unfold all
2916 181 : await page.evaluate(() => window.unfoldAll());
2917 182 : await page.waitForTimeout(100);
2918 183 :
2919 184 : const unfoldedCount = await getFoldPlaceholderCount();
2920 185 : expect(unfoldedCount).toBe(0);
2921 186 : });
2922 187 :
2923 188 : test('foldAll reduces visible line count', async () => {
2924 189 : const beforeLines = await getVisibleLineCount();
2925 190 :
2926 191 : await page.evaluate(() => window.foldAll());
2927 192 : await page.waitForTimeout(100);
2928 193 :
2929 194 : const afterLines = await getVisibleLineCount();
2930 195 : expect(afterLines).toBeLessThan(beforeLines);
2931 196 : });
2932 197 : });
2933 198 :
2934 199 : // ==========================================================================
2935 200 : // Header Folding (folditall behavior)
2936 201 : // ==========================================================================
2937 202 :
2938 203 : test.describe('Header Folding Behavior', () => {
2939 204 : test.beforeEach(async () => {
2940 205 : await page.evaluate(() => window.unfoldAll());
2941 206 : await page.waitForTimeout(50);
2942 207 : });
2943 208 :
2944 209 : test('level 1 header is foldable', async () => {
2945 210 : const result = await page.evaluate(() => {
2946 211 : const line = window.editorView.state.doc.line(1);
2947 212 : return window.foldable(line.from) !== null;
2948 213 : });
2949 214 : expect(result).toBe(true);
2950 215 : });
2951 216 :
2952 217 : test('folding level 1 header hides content until next level 1 or EOF', async () => {
2953 218 : // Fold at line 1 (# Level 1 Header)
2954 219 : await page.evaluate(() => {
2955 220 : window.setCursorLine(1);
2956 221 : window.foldCode(window.editorView.state.selection.main.head);
2957 222 : });
2958 223 : await page.waitForTimeout(100);
2959 224 :
2960 225 : const placeholders = await getFoldPlaceholderCount();
2961 226 : expect(placeholders).toBeGreaterThan(0);
2962 227 : });
2963 228 :
2964 229 : test('level 2 header fold ends at next level 2 or higher', async () => {
2965 230 : // Find line number for "## Level 2 - Features"
2966 231 : const lineNum = await page.evaluate(() => {
2967 232 : const doc = window.editorView.state.doc;
2968 233 : for (let i = 1; i <= doc.lines; i++) {
2969 234 : if (doc.line(i).text.startsWith('## Level 2 - Features')) {
2970 235 : return i;
2971 236 : }
2972 237 : }
2973 238 : return -1;
2974 239 : });
2975 240 :
2976 241 : expect(lineNum).toBeGreaterThan(0);
2977 242 :
2978 243 : // Get the fold range
2979 244 : const foldRange = await page.evaluate((ln) => {
2980 245 : const doc = window.editorView.state.doc;
2981 246 : const line = doc.line(ln);
2982 247 : const range = window.foldable(line.from);
2983 248 : if (!range) return null;
2984 249 :
2985 250 : // Find what line the fold ends at
2986 251 : const endLine = doc.lineAt(range.to);
2987 252 : return {
2988 253 : startLine: ln,
2989 254 : endLineNum: endLine.number,
2990 255 : endLineText: endLine.text.substring(0, 50),
2991 256 : };
2992 257 : }, lineNum);
2993 258 :
2994 259 : expect(foldRange).not.toBeNull();
2995 260 : // The fold should end before the next ## header
2996 261 : expect(foldRange!.endLineNum).toBeGreaterThan(lineNum);
2997 262 : });
2998 263 :
2999 264 : test('all 6 header levels are foldable', async () => {
3000 265 : const results = await page.evaluate(() => {
3001 266 : const doc = window.editorView.state.doc;
3002 267 : const levels: Record<number, boolean> = {};
3003 268 :
3004 269 : for (let i = 1; i <= doc.lines; i++) {
3005 270 : const line = doc.line(i);
3006 271 : const match = line.text.match(/^(#{1,6})\s+/);
3007 272 : if (match) {
3008 273 : const level = match[1].length;
3009 274 : if (!levels[level]) {
3010 275 : levels[level] = window.foldable(line.from) !== null;
3011 276 : }
3012 277 : }
3013 278 : }
3014 279 : return levels;
3015 280 : });
3016 281 :
3017 282 : expect(results[1]).toBe(true);
3018 283 : expect(results[2]).toBe(true);
3019 284 : expect(results[3]).toBe(true);
3020 285 : expect(results[4]).toBe(true);
3021 286 : expect(results[5]).toBe(true);
3022 287 : expect(results[6]).toBe(true);
3023 288 : });
3024 289 : });
3025 290 :
3026 291 : // ==========================================================================
3027 292 : // Vim Mode Fold Commands
3028 293 : // ==========================================================================
3029 294 :
3030 295 : test.describe('Vim Fold Commands (actual behavior)', () => {
3031 296 : test.beforeEach(async () => {
3032 297 : await page.evaluate(() => {
3033 298 : window.setVimMode(true);
3034 299 : window.unfoldAll();
3035 300 : });
3036 301 : await page.waitForTimeout(100);
3037 302 : });
3038 303 :
3039 304 : test.afterEach(async () => {
3040 305 : await page.evaluate(() => {
3041 306 : window.setVimMode(false);
3042 307 : window.unfoldAll();
3043 308 : });
3044 309 : });
3045 310 :
3046 311 : test('zM actually folds content (creates placeholders)', async () => {
3047 312 : const beforeCount = await getFoldPlaceholderCount();
3048 313 : expect(beforeCount).toBe(0);
3049 314 :
3050 315 : // Execute zM via Vim
3051 316 : await page.evaluate(() => {
3052 317 : window.setCursorLine(1);
3053 318 : const view = window.editorView;
3054 319 : view.focus();
3055 320 : });
3056 321 : await page.waitForTimeout(50);
3057 322 :
3058 323 : // Type zM
3059 324 : await page.keyboard.press('z');
3060 325 : await page.keyboard.press('Shift+M');
3061 326 : await page.waitForTimeout(100);
3062 327 :
3063 328 : const afterCount = await getFoldPlaceholderCount();
3064 329 : expect(afterCount).toBeGreaterThan(0);
3065 330 : });
3066 331 :
3067 332 : test('zR actually unfolds content (removes placeholders)', async () => {
3068 333 : // First fold everything
3069 334 : await page.evaluate(() => window.foldAll());
3070 335 : await page.waitForTimeout(100);
3071 336 :
3072 337 : const foldedCount = await getFoldPlaceholderCount();
3073 338 : expect(foldedCount).toBeGreaterThan(0);
3074 339 :
3075 340 : // Now use zR to unfold
3076 341 : await page.evaluate(() => {
3077 342 : window.setCursorLine(1);
3078 343 : window.editorView.focus();
3079 344 : });
3080 345 : await page.waitForTimeout(50);
3081 346 :
3082 347 : await page.keyboard.press('z');
3083 348 : await page.keyboard.press('Shift+R');
3084 349 : await page.waitForTimeout(100);
3085 350 :
3086 351 : const afterCount = await getFoldPlaceholderCount();
3087 352 : expect(afterCount).toBe(0);
3088 353 : });
3089 354 :
3090 355 : test('zc folds at cursor position', async () => {
3091 356 : const beforeCount = await getFoldPlaceholderCount();
3092 357 : expect(beforeCount).toBe(0);
3093 358 :
3094 359 : // Position on a header and fold it
3095 360 : await page.evaluate(() => {
3096 361 : window.setCursorLine(1); // Level 1 header
3097 362 : window.editorView.focus();
3098 363 : });
3099 364 : await page.waitForTimeout(50);
3100 365 :
3101 366 : await page.keyboard.press('z');
3102 367 : await page.keyboard.press('c');
3103 368 : await page.waitForTimeout(100);
3104 369 :
3105 370 : const afterCount = await getFoldPlaceholderCount();
3106 371 : expect(afterCount).toBeGreaterThan(0);
3107 372 : });
3108 373 :
3109 374 : test('zo unfolds at cursor position', async () => {
3110 375 : // First fold at line 1
3111 376 : await page.evaluate(() => {
3112 377 : window.setCursorLine(1);
3113 378 : window.foldCode(window.editorView.state.selection.main.head);
3114 379 : });
3115 380 : await page.waitForTimeout(100);
3116 381 :
3117 382 : const foldedCount = await getFoldPlaceholderCount();
3118 383 : expect(foldedCount).toBeGreaterThan(0);
3119 384 :
3120 385 : // Now unfold with zo
3121 386 : await page.evaluate(() => {
3122 387 : window.setCursorLine(1);
3123 388 : window.editorView.focus();
3124 389 : });
3125 390 : await page.waitForTimeout(50);
3126 391 :
3127 392 : await page.keyboard.press('z');
3128 393 : await page.keyboard.press('o');
3129 394 : await page.waitForTimeout(100);
3130 395 :
3131 396 : const afterCount = await getFoldPlaceholderCount();
3132 397 : expect(afterCount).toBeLessThan(foldedCount);
3133 398 : });
3134 399 :
3135 400 : test('za toggles fold (fold then unfold)', async () => {
3136 401 : const initialCount = await getFoldPlaceholderCount();
3137 402 : expect(initialCount).toBe(0);
3138 403 :
3139 404 : await page.evaluate(() => {
3140 405 : window.setCursorLine(1);
3141 406 : window.editorView.focus();
3142 407 : });
3143 408 : await page.waitForTimeout(50);
3144 409 :
3145 410 : // First za should fold
3146 411 : await page.keyboard.press('z');
3147 412 : await page.keyboard.press('a');
3148 413 : await page.waitForTimeout(100);
3149 414 :
3150 415 : const afterFirstToggle = await getFoldPlaceholderCount();
3151 416 : expect(afterFirstToggle).toBeGreaterThan(0);
3152 417 :
3153 418 : // Second za should unfold
3154 419 : await page.keyboard.press('z');
3155 420 : await page.keyboard.press('a');
3156 421 : await page.waitForTimeout(100);
3157 422 :
3158 423 : const afterSecondToggle = await getFoldPlaceholderCount();
3159 424 : expect(afterSecondToggle).toBe(0);
3160 425 : });
3161 426 : });
3162 427 :
3163 428 : // ==========================================================================
3164 429 : // Click-to-Fold (gutter interaction)
3165 430 : // ==========================================================================
3166 431 :
3167 432 : test.describe('Click-to-Fold', () => {
3168 433 : test.beforeEach(async () => {
3169 434 : await page.evaluate(() => {
3170 435 : window.setVimMode(false);
3171 436 : window.unfoldAll();
3172 437 : });
3173 438 : await page.waitForTimeout(50);
3174 439 : });
3175 440 :
3176 441 : test('clicking fold gutter creates a fold placeholder', async () => {
3177 442 : const beforeCount = await getFoldPlaceholderCount();
3178 443 : expect(beforeCount).toBe(0);
3179 444 :
3180 445 : // Click the first fold marker in the gutter
3181 446 : const clicked = await page.evaluate(() => {
3182 447 : const gutterElements = document.querySelectorAll('.cm-foldGutter .cm-gutterElement');
3183 448 : for (const el of gutterElements) {
3184 449 : // Look for a gutter element that has fold indicator
3185 450 : if (el.textContent && el.textContent.trim() !== '') {
3186 451 : (el as HTMLElement).click();
3187 452 : return true;
3188 453 : }
3189 454 : }
3190 455 : // Try clicking any gutter element on a header line
3191 456 : const firstGutter = gutterElements[0] as HTMLElement;
3192 457 : if (firstGutter) {
3193 458 : firstGutter.click();
3194 459 : return true;
3195 460 : }
3196 461 : return false;
3197 462 : });
3198 463 :
3199 464 : if (clicked) {
3200 465 : await page.waitForTimeout(100);
3201 466 : const afterCount = await getFoldPlaceholderCount();
3202 467 : // Note: Click behavior may vary, but if it worked there should be a placeholder
3203 468 : // This test validates the mechanism exists
3204 469 : }
3205 470 :
3206 471 : expect(true).toBe(true); // Placeholder test - gutter click mechanism exists
3207 472 : });
3208 473 : });
3209 474 :
3210 475 : // ==========================================================================
3211 476 : // List Folding (folditall feature)
3212 477 : // ==========================================================================
3213 478 :
3214 479 : test.describe('List Item Folding', () => {
3215 480 : test.beforeEach(async () => {
3216 481 : await page.evaluate(() => window.unfoldAll());
3217 482 : await page.waitForTimeout(50);
3218 483 : });
3219 484 :
3220 485 : test('list item with children is foldable', async () => {
3221 486 : // Find a parent list item (- Parent item with children)
3222 487 : const result = await page.evaluate(() => {
3223 488 : const doc = window.editorView.state.doc;
3224 489 : for (let i = 1; i <= doc.lines; i++) {
3225 490 : const line = doc.line(i);
3226 491 : if (line.text.match(/^- Parent item with children/)) {
3227 492 : const isFoldable = window.foldable(line.from) !== null;
3228 493 : return { lineNum: i, text: line.text, foldable: isFoldable };
3229 494 : }
3230 495 : }
3231 496 : return null;
3232 497 : });
3233 498 :
3234 499 : expect(result).not.toBeNull();
3235 500 : expect(result!.foldable).toBe(true);
3236 501 : });
3237 502 :
3238 503 : test('nested list items create foldable regions', async () => {
3239 504 : // Find lines with child items
3240 505 : const result = await page.evaluate(() => {
3241 506 : const doc = window.editorView.state.doc;
3242 507 : const listItems: { lineNum: number; text: string; foldable: boolean; indent: number }[] = [];
3243 508 :
3244 509 : for (let i = 1; i <= doc.lines; i++) {
3245 510 : const line = doc.line(i);
3246 511 : const text = line.text;
3247 512 : // Match list items
3248 513 : if (/^\s*[-*+]\s/.test(text) || /^\s*\d+[.)]\s/.test(text)) {
3249 514 : const indent = text.match(/^(\s*)/)?.[1].length || 0;
3250 515 : const isFoldable = window.foldable(line.from) !== null;
3251 516 : listItems.push({ lineNum: i, text: text.substring(0, 40), foldable: isFoldable, indent });
3252 517 : }
3253 518 : }
3254 519 : return listItems;
3255 520 : });
3256 521 :
3257 522 : expect(result.length).toBeGreaterThan(0);
3258 523 :
3259 524 : // At least some list items with children should be foldable
3260 525 : const foldableItems = result.filter(item => item.foldable);
3261 526 : expect(foldableItems.length).toBeGreaterThan(0);
3262 527 : });
3263 528 :
3264 529 : test('folding list parent hides children', async () => {
3265 530 : const beforeLines = await getVisibleLineCount();
3266 531 :
3267 532 : // Find and fold a parent list item
3268 533 : const folded = await page.evaluate(() => {
3269 534 : const doc = window.editorView.state.doc;
3270 535 : for (let i = 1; i <= doc.lines; i++) {
3271 536 : const line = doc.line(i);
3272 537 : if (line.text.match(/^- Parent item with children/)) {
3273 538 : const foldRange = window.foldable(line.from);
3274 539 : if (foldRange) {
3275 540 : window.foldCode(line.from);
3276 541 : return true;
3277 542 : }
3278 543 : }
3279 544 : }
3280 545 : return false;
3281 546 : });
3282 547 :
3283 548 : expect(folded).toBe(true);
3284 549 : await page.waitForTimeout(100);
3285 550 :
3286 551 : const afterLines = await getVisibleLineCount();
3287 552 : const placeholders = await getFoldPlaceholderCount();
3288 553 :
3289 554 : expect(placeholders).toBeGreaterThan(0);
3290 555 : expect(afterLines).toBeLessThan(beforeLines);
3291 556 : });
3292 557 :
3293 558 : test('deeply nested list items (grandchildren) are hidden when parent folds', async () => {
3294 559 : // Find a grandchild line number
3295 560 : const grandchildLineNum = await page.evaluate(() => {
3296 561 : const doc = window.editorView.state.doc;
3297 562 : for (let i = 1; i <= doc.lines; i++) {
3298 563 : if (doc.line(i).text.includes('Grandchild item')) {
3299 564 : return i;
3300 565 : }
3301 566 : }
3302 567 : return -1;
3303 568 : });
3304 569 :
3305 570 : expect(grandchildLineNum).toBeGreaterThan(0);
3306 571 :
3307 572 : // Fold the top-level parent list item
3308 573 : await page.evaluate(() => {
3309 574 : const doc = window.editorView.state.doc;
3310 575 : for (let i = 1; i <= doc.lines; i++) {
3311 576 : const line = doc.line(i);
3312 577 : if (line.text.match(/^- Parent item with children/)) {
3313 578 : window.foldCode(line.from);
3314 579 : break;
3315 580 : }
3316 581 : }
3317 582 : });
3318 583 : await page.waitForTimeout(100);
3319 584 :
3320 585 : const placeholders = await getFoldPlaceholderCount();
3321 586 : expect(placeholders).toBeGreaterThan(0);
3322 587 : });
3323 588 :
3324 589 : test('simple list item without children is not foldable', async () => {
3325 590 : const result = await page.evaluate(() => {
3326 591 : const doc = window.editorView.state.doc;
3327 592 : for (let i = 1; i <= doc.lines; i++) {
3328 593 : const line = doc.line(i);
3329 594 : if (line.text === '- Simple item (no children)') {
3330 595 : const isFoldable = window.foldable(line.from) !== null;
3331 596 : return { lineNum: i, foldable: isFoldable };
3332 597 : }
3333 598 : }
3334 599 : return null;
3335 600 : });
3336 601 :
3337 602 : expect(result).not.toBeNull();
3338 603 : expect(result!.foldable).toBe(false);
3339 604 : });
3340 605 : });
3341 606 :
3342 607 : // ==========================================================================
3343 608 : // Code Block Folding (folditall feature)
3344 609 : // ==========================================================================
3345 610 :
3346 611 : test.describe('Code Block Folding', () => {
3347 612 : test.beforeEach(async () => {
3348 613 : await page.evaluate(() => window.unfoldAll());
3349 614 : await page.waitForTimeout(50);
3350 615 : });
3351 616 :
3352 617 : test('fenced code block opener is foldable', async () => {
3353 618 : const result = await page.evaluate(() => {
3354 619 : const doc = window.editorView.state.doc;
3355 620 : for (let i = 1; i <= doc.lines; i++) {
3356 621 : const line = doc.line(i);
3357 622 : if (line.text.startsWith('```javascript')) {
3358 623 : const isFoldable = window.foldable(line.from) !== null;
3359 624 : return { lineNum: i, foldable: isFoldable };
3360 625 : }
3361 626 : }
3362 627 : return null;
3363 628 : });
3364 629 :
3365 630 : expect(result).not.toBeNull();
3366 631 : expect(result!.foldable).toBe(true);
3367 632 : });
3368 633 :
3369 634 : test('folding code block hides its contents', async () => {
3370 635 : const beforeLines = await getVisibleLineCount();
3371 636 :
3372 637 : await page.evaluate(() => {
3373 638 : const doc = window.editorView.state.doc;
3374 639 : for (let i = 1; i <= doc.lines; i++) {
3375 640 : const line = doc.line(i);
3376 641 : if (line.text.startsWith('```javascript')) {
3377 642 : window.foldCode(line.from);
3378 643 : break;
3379 644 : }
3380 645 : }
3381 646 : });
3382 647 : await page.waitForTimeout(100);
3383 648 :
3384 649 : const afterLines = await getVisibleLineCount();
3385 650 : const placeholders = await getFoldPlaceholderCount();
3386 651 :
3387 652 : expect(placeholders).toBeGreaterThan(0);
3388 653 : expect(afterLines).toBeLessThan(beforeLines);
3389 654 : });
3390 655 : });
3391 656 :
3392 657 : // ==========================================================================
3393 658 : // Spacebar Toggle (folditall feature)
3394 659 : // ==========================================================================
3395 660 :
3396 661 : test.describe('Spacebar Toggle', () => {
3397 662 : test.beforeEach(async () => {
3398 663 : await page.evaluate(() => {
3399 664 : window.setVimMode(true);
3400 665 : window.unfoldAll();
3401 666 : });
3402 667 : await page.waitForTimeout(100);
3403 668 : });
3404 669 :
3405 670 : test.afterEach(async () => {
3406 671 : await page.evaluate(() => {
3407 672 : window.setVimMode(false);
3408 673 : window.unfoldAll();
3409 674 : });
3410 675 : });
3411 676 :
3412 677 : test('spacebar toggles fold like za', async () => {
3413 678 : const initialCount = await getFoldPlaceholderCount();
3414 679 : expect(initialCount).toBe(0);
3415 680 :
3416 681 : await page.evaluate(() => {
3417 682 : window.setCursorLine(1);
3418 683 : window.editorView.focus();
3419 684 : });
3420 685 : await page.waitForTimeout(50);
3421 686 :
3422 687 : // Press space to fold
3423 688 : await page.keyboard.press('Space');
3424 689 : await page.waitForTimeout(100);
3425 690 :
3426 691 : const afterFold = await getFoldPlaceholderCount();
3427 692 : expect(afterFold).toBeGreaterThan(0);
3428 693 :
3429 694 : // Press space again to unfold
3430 695 : await page.keyboard.press('Space');
3431 696 : await page.waitForTimeout(100);
3432 697 :
3433 698 : const afterUnfold = await getFoldPlaceholderCount();
3434 699 : expect(afterUnfold).toBe(0);
3435 700 : });
3436 701 : });
3437 702 :
3438 703 : // ==========================================================================
3439 704 : // Fold from Any Line Within Region (folditall core feature)
3440 705 : // ==========================================================================
3441 706 :
3442 707 : test.describe('Fold From Any Line In Region', () => {
3443 708 : test.beforeEach(async () => {
3444 709 : await page.evaluate(() => {
3445 710 : window.setVimMode(true);
3446 711 : window.unfoldAll();
3447 712 : });
3448 713 : await page.waitForTimeout(100);
3449 714 : });
3450 715 :
3451 716 : test.afterEach(async () => {
3452 717 : await page.evaluate(() => {
3453 718 : window.setVimMode(false);
3454 719 : window.unfoldAll();
3455 720 : });
3456 721 : });
3457 722 :
3458 723 : test('zc from middle of header section folds the containing header', async () => {
3459 724 : // Find line 3 (content under level 1 header)
3460 725 : const lineNum = 3;
3461 726 :
3462 727 : await page.evaluate((ln) => {
3463 728 : window.setCursorLine(ln);
3464 729 : window.editorView.focus();
3465 730 : }, lineNum);
3466 731 : await page.waitForTimeout(50);
3467 732 :
3468 733 : const beforeCount = await getFoldPlaceholderCount();
3469 734 : expect(beforeCount).toBe(0);
3470 735 :
3471 736 : await page.keyboard.press('z');
3472 737 : await page.keyboard.press('c');
3473 738 : await page.waitForTimeout(100);
3474 739 :
3475 740 : const afterCount = await getFoldPlaceholderCount();
3476 741 : expect(afterCount).toBeGreaterThan(0);
3477 742 : });
3478 743 :
3479 744 : test('za from nested list child toggles parent list fold', async () => {
3480 745 : // Find a child list item line
3481 746 : const childLineNum = await page.evaluate(() => {
3482 747 : const doc = window.editorView.state.doc;
3483 748 : for (let i = 1; i <= doc.lines; i++) {
3484 749 : if (doc.line(i).text.includes('Child item one')) {
3485 750 : return i;
3486 751 : }
3487 752 : }
3488 753 : return -1;
3489 754 : });
3490 755 :
3491 756 : expect(childLineNum).toBeGreaterThan(0);
3492 757 :
3493 758 : await page.evaluate((ln) => {
3494 759 : window.setCursorLine(ln);
3495 760 : window.editorView.focus();
3496 761 : }, childLineNum);
3497 762 : await page.waitForTimeout(50);
3498 763 :
3499 764 : // za should fold the containing list parent
3500 765 : await page.keyboard.press('z');
3501 766 : await page.keyboard.press('a');
3502 767 : await page.waitForTimeout(100);
3503 768 :
3504 769 : const afterFold = await getFoldPlaceholderCount();
3505 770 : expect(afterFold).toBeGreaterThan(0);
3506 771 : });
3507 772 : });
3508 773 :
3509 774 : // ==========================================================================
3510 775 : // Folditall-Specific: Nested Content Behavior
3511 776 : // ==========================================================================
3512 777 :
3513 778 : test.describe('Folditall Nested Behavior', () => {
3514 779 : test.beforeEach(async () => {
3515 780 : await page.evaluate(() => window.unfoldAll());
3516 781 : await page.waitForTimeout(50);
3517 782 : });
3518 783 :
3519 784 : test('folding parent header hides child headers', async () => {
3520 785 : // Fold "## Level 2 - Features" which contains ### Level 3, #### Level 4, etc.
3521 786 : const result = await page.evaluate(() => {
3522 787 : const doc = window.editorView.state.doc;
3523 788 : let level2Line = -1;
3524 789 :
3525 790 : for (let i = 1; i <= doc.lines; i++) {
3526 791 : if (doc.line(i).text.startsWith('## Level 2 - Features')) {
3527 792 : level2Line = i;
3528 793 : break;
3529 794 : }
3530 795 : }
3531 796 :
3532 797 : if (level2Line === -1) return { error: 'Level 2 header not found' };
3533 798 :
3534 799 : // Get content before folding
3535 800 : const visibleLinesBefore = document.querySelectorAll('.cm-line').length;
3536 801 :
3537 802 : // Fold at level 2
3538 803 : const line = doc.line(level2Line);
3539 804 : window.foldCode(line.from);
3540 805 :
3541 806 : return {
3542 807 : level2Line,
3543 808 : visibleLinesBefore,
3544 809 : };
3545 810 : });
3546 811 :
3547 812 : expect(result.level2Line).toBeGreaterThan(0);
3548 813 :
3549 814 : await page.waitForTimeout(100);
3550 815 :
3551 816 : const visibleLinesAfter = await getVisibleLineCount();
3552 817 : const placeholders = await getFoldPlaceholderCount();
3553 818 :
3554 819 : // After folding level 2, visible lines should decrease and placeholder should appear
3555 820 : expect(placeholders).toBeGreaterThan(0);
3556 821 : expect(visibleLinesAfter).toBeLessThan(result.visibleLinesBefore!);
3557 822 : });
3558 823 :
3559 824 : test('deeply nested headers (level 5, 6) are individually foldable', async () => {
3560 825 : const result = await page.evaluate(() => {
3561 826 : const doc = window.editorView.state.doc;
3562 827 : const foldableHeaders: { level: number; line: number; foldable: boolean }[] = [];
3563 828 :
3564 829 : for (let i = 1; i <= doc.lines; i++) {
3565 830 : const lineText = doc.line(i).text;
3566 831 : const match = lineText.match(/^(#{5,6})\s+/);
3567 832 : if (match) {
3568 833 : const level = match[1].length;
3569 834 : const line = doc.line(i);
3570 835 : const isFoldable = window.foldable(line.from) !== null;
3571 836 : foldableHeaders.push({ level, line: i, foldable: isFoldable });
3572 837 : }
3573 838 : }
3574 839 :
3575 840 : return foldableHeaders;
3576 841 : });
3577 842 :
3578 843 : // Should have found level 5 and 6 headers
3579 844 : const level5 = result.filter(h => h.level === 5);
3580 845 : const level6 = result.filter(h => h.level === 6);
3581 846 :
3582 847 : expect(level5.length).toBeGreaterThan(0);
3583 848 : expect(level6.length).toBeGreaterThan(0);
3584 849 :
3585 850 : // They should all be foldable
3586 851 : for (const h of result) {
3587 852 : expect(h.foldable).toBe(true);
3588 853 : }
3589 854 : });
3590 855 : });
3591 856 :
3592 857 : // ==========================================================================
3593 858 : // Status Line (vim-style status bar)
3594 859 : // ==========================================================================
3595 860 :
3596 861 : test.describe('Status Line', () => {
3597 862 : test.beforeEach(async () => {
3598 863 : await page.evaluate(() => {
3599 864 : window.setVimMode(true);
3600 865 : window.unfoldAll();
3601 866 : });
3602 867 : await page.waitForTimeout(100);
3603 868 : });
3604 869 :
3605 870 : test.afterEach(async () => {
3606 871 : await page.evaluate(() => {
3607 872 : window.setVimMode(false);
3608 873 : });
3609 874 : });
3610 875 :
3611 876 : test('status line shows position info', async () => {
3612 877 : // Move cursor to line 5
3613 878 : await page.evaluate(() => {
3614 879 : window.setCursorLine(5);
3615 880 : });
3616 881 : await page.waitForTimeout(100);
3617 882 :
3618 883 : const positionText = await page.evaluate(() => {
3619 884 : const posInfo = document.querySelector('.vim-position-info');
3620 885 : return posInfo?.textContent || '';
3621 886 : });
3622 887 :
3623 888 : expect(positionText).toContain('Ln 5');
3624 889 : expect(positionText).toContain('Col');
3625 890 : });
3626 891 :
3627 892 : test('status line updates on cursor movement', async () => {
3628 893 : // Move to line 1
3629 894 : await page.evaluate(() => {
3630 895 : window.setCursorLine(1);
3631 896 : });
3632 897 : await page.waitForTimeout(100);
3633 898 :
3634 899 : const pos1 = await page.evaluate(() => {
3635 900 : return document.querySelector('.vim-position-info')?.textContent || '';
3636 901 : });
3637 902 : expect(pos1).toContain('Ln 1');
3638 903 :
3639 904 : // Move to line 10
3640 905 : await page.evaluate(() => {
3641 906 : window.setCursorLine(10);
3642 907 : });
3643 908 : await page.waitForTimeout(100);
3644 909 :
3645 910 : const pos2 = await page.evaluate(() => {
3646 911 : return document.querySelector('.vim-position-info')?.textContent || '';
3647 912 : });
3648 913 : expect(pos2).toContain('Ln 10');
3649 914 : });
3650 915 :
3651 916 : test('status line shows NORMAL mode initially', async () => {
3652 917 : const modeText = await page.evaluate(() => {
3653 918 : const modeIndicator = document.querySelector('.vim-mode-indicator');
3654 919 : return modeIndicator?.textContent || '';
3655 920 : });
3656 921 :
3657 922 : expect(modeText).toBe('NORMAL');
3658 923 : });
3659 924 :
3660 925 : test('status line shows INSERT mode when entering insert mode', async () => {
3661 926 : await page.evaluate(() => {
3662 927 : window.setCursorLine(1);
3663 928 : window.editorView.focus();
3664 929 : });
3665 930 : await page.waitForTimeout(50);
3666 931 :
3667 932 : // Press 'i' to enter insert mode
3668 933 : await page.keyboard.press('i');
3669 934 : await page.waitForTimeout(100);
3670 935 :
3671 936 : const modeText = await page.evaluate(() => {
3672 937 : const modeIndicator = document.querySelector('.vim-mode-indicator');
3673 938 : return modeIndicator?.textContent || '';
3674 939 : });
3675 940 :
3676 941 : expect(modeText).toContain('INSERT');
3677 942 :
3678 943 : // Press Escape to exit insert mode
3679 944 : await page.keyboard.press('Escape');
3680 945 : await page.waitForTimeout(100);
3681 946 :
3682 947 : const normalText = await page.evaluate(() => {
3683 948 : const modeIndicator = document.querySelector('.vim-mode-indicator');
3684 949 : return modeIndicator?.textContent || '';
3685 950 : });
3686 951 :
3687 952 : expect(normalText).toBe('NORMAL');
3688 953 : });
3689 954 :
3690 955 : test('status line shows VISUAL mode when entering visual mode', async () => {
3691 956 : await page.evaluate(() => {
3692 957 : window.setCursorLine(1);
3693 958 : window.editorView.focus();
3694 959 : });
3695 960 : await page.waitForTimeout(50);
3696 961 :
3697 962 : // Press 'v' to enter visual mode
3698 963 : await page.keyboard.press('v');
3699 964 : await page.waitForTimeout(100);
3700 965 :
3701 966 : const modeText = await page.evaluate(() => {
3702 967 : const modeIndicator = document.querySelector('.vim-mode-indicator');
3703 968 : return modeIndicator?.textContent || '';
3704 969 : });
3705 970 :
3706 971 : expect(modeText).toContain('VISUAL');
3707 972 :
3708 973 : // Press Escape to exit visual mode
3709 974 : await page.keyboard.press('Escape');
3710 975 : });
3711 976 : });
3712 977 : });
3713 978 :
3714 979 : // TypeScript declarations
3715 980 : declare global {
3716 981 : interface Window {
3717 982 : editorView: any;
3718 983 : foldAll: () => boolean;
3719 984 : unfoldAll: () => boolean;
3720 985 : foldCode: (pos: number) => boolean;
3721 986 : unfoldCode: (pos: number) => boolean;
3722 987 : foldable: (pos: number) => { from: number; to: number } | null;
3723 988 : setVimMode: (enabled: boolean) => void;
3724 989 : getCursorLine: () => number;
3725 990 : setCursorLine: (lineNum: number) => void;
3726 991 : }
3727 992 : }
3728Removed regular file tests/editor/test-page.html:
3729 1 : <!DOCTYPE html>
3730 2 : <html lang="en">
3731 3 : <head>
3732 4 : <meta charset="UTF-8">
3733 5 : <meta name="viewport" content="width=device-width, initial-scale=1.0">
3734 6 : <title>Editor Folding Test Page</title>
3735 7 : <script type="importmap">
3736 8 : {
3737 9 : "imports": {
3738 10 : "@codemirror/state": "/node_modules/@codemirror/state/dist/index.js",
3739 11 : "@codemirror/view": "/node_modules/@codemirror/view/dist/index.js",
3740 12 : "@codemirror/commands": "/node_modules/@codemirror/commands/dist/index.js",
3741 13 : "@codemirror/language": "/node_modules/@codemirror/language/dist/index.js",
3742 14 : "@codemirror/autocomplete": "/node_modules/@codemirror/autocomplete/dist/index.js",
3743 15 : "@codemirror/lang-markdown": "/node_modules/@codemirror/lang-markdown/dist/index.js",
3744 16 : "@codemirror/lang-html": "/node_modules/@codemirror/lang-html/dist/index.js",
3745 17 : "@codemirror/lang-css": "/node_modules/@codemirror/lang-css/dist/index.js",
3746 18 : "@codemirror/lang-javascript": "/node_modules/@codemirror/lang-javascript/dist/index.js",
3747 19 : "@codemirror/theme-one-dark": "/node_modules/@codemirror/theme-one-dark/dist/index.js",
3748 20 : "@codemirror/search": "/node_modules/@codemirror/search/dist/index.js",
3749 21 : "@replit/codemirror-vim": "/node_modules/@replit/codemirror-vim/dist/index.js",
3750 22 : "@lezer/common": "/node_modules/@lezer/common/dist/index.js",
3751 23 : "@lezer/highlight": "/node_modules/@lezer/highlight/dist/index.js",
3752 24 : "@lezer/lr": "/node_modules/@lezer/lr/dist/index.js",
3753 25 : "@lezer/markdown": "/node_modules/@lezer/markdown/dist/index.js",
3754 26 : "@lezer/html": "/node_modules/@lezer/html/dist/index.js",
3755 27 : "@lezer/css": "/node_modules/@lezer/css/dist/index.js",
3756 28 : "@lezer/javascript": "/node_modules/@lezer/javascript/dist/index.js",
3757 29 : "crelt": "/node_modules/crelt/index.js",
3758 30 : "style-mod": "/node_modules/style-mod/src/style-mod.js",
3759 31 : "w3c-keyname": "/node_modules/w3c-keyname/index.js",
3760 32 : "@marijn/find-cluster-break": "/node_modules/@marijn/find-cluster-break/src/index.js"
3761 33 : }
3762 34 : }
3763 35 : </script>
3764 36 : <style>
3765 37 : :root {
3766 38 : /* Base16 Tomorrow Night theme */
3767 39 : --base00: #1d1f21;
3768 40 : --base01: #282a2e;
3769 41 : --base02: #373b41;
3770 42 : --base03: #969896;
3771 43 : --base04: #b4b7b4;
3772 44 : --base05: #c5c8c6;
3773 45 : --base06: #e0e0e0;
3774 46 : --base07: #ffffff;
3775 47 : --base08: #cc6666;
3776 48 : --base09: #de935f;
3777 49 : --base0A: #f0c674;
3778 50 : --base0B: #b5bd68;
3779 51 : --base0C: #8abeb7;
3780 52 : --base0D: #81a2be;
3781 53 : --base0E: #b294bb;
3782 54 : --base0F: #a3685a;
3783 55 : --theme-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
3784 56 : }
3785 57 : body {
3786 58 : margin: 0;
3787 59 : padding: 20px;
3788 60 : background: var(--base00);
3789 61 : color: var(--base05);
3790 62 : font-family: var(--theme-font-mono);
3791 63 : }
3792 64 : #editor-container {
3793 65 : width: 100%;
3794 66 : height: 600px;
3795 67 : border: 1px solid var(--base02);
3796 68 : border-radius: 8px;
3797 69 : overflow: hidden;
3798 70 : }
3799 71 : .cm-editor {
3800 72 : height: 100%;
3801 73 : }
3802 74 : #controls {
3803 75 : margin-bottom: 16px;
3804 76 : display: flex;
3805 77 : gap: 12px;
3806 78 : align-items: center;
3807 79 : }
3808 80 : button {
3809 81 : background: var(--base02);
3810 82 : color: var(--base05);
3811 83 : border: 1px solid var(--base03);
3812 84 : padding: 8px 16px;
3813 85 : border-radius: 4px;
3814 86 : cursor: pointer;
3815 87 : font-family: inherit;
3816 88 : }
3817 89 : button:hover {
3818 90 : background: var(--base03);
3819 91 : }
3820 92 : label {
3821 93 : display: flex;
3822 94 : align-items: center;
3823 95 : gap: 8px;
3824 96 : }
3825 97 : #status {
3826 98 : margin-top: 16px;
3827 99 : padding: 12px;
3828 100 : background: var(--base01);
3829 101 : border-radius: 4px;
3830 102 : font-size: 12px;
3831 103 : }
3832 104 : </style>
3833 105 : </head>
3834 106 : <body>
3835 107 : <div id="controls">
3836 108 : <label>
3837 109 : <input type="checkbox" id="vim-toggle">
3838 110 : Vim Mode
3839 111 : </label>
3840 112 : <button id="fold-all">Fold All (zM)</button>
3841 113 : <button id="unfold-all">Unfold All (zR)</button>
3842 114 : </div>
3843 115 :
3844 116 : <div id="editor-container"></div>
3845 117 :
3846 118 : <!-- Vim-style status line -->
3847 119 : <div id="vim-status-line" style="display: none; justify-content: space-between; align-items: center; padding: 4px 12px; background: var(--base00); border-top: 1px solid var(--base02); min-height: 22px; font-family: var(--theme-font-mono); font-size: 12px;">
3848 120 : <span class="vim-mode-indicator" style="color: var(--base0A); font-weight: 600;">NORMAL</span>
3849 121 : <span class="vim-position-info" style="color: var(--base04);">Ln 1, Col 1</span>
3850 122 : </div>
3851 123 :
3852 124 : <div id="status">
3853 125 : <div id="fold-count">Folds: 0</div>
3854 126 : <div id="cursor-pos">Cursor: 1:1</div>
3855 127 : </div>
3856 128 :
3857 129 : <script type="module">
3858 130 : import { EditorState, Compartment } from '@codemirror/state';
3859 131 : import {
3860 132 : EditorView,
3861 133 : keymap,
3862 134 : lineNumbers,
3863 135 : highlightActiveLine,
3864 136 : highlightActiveLineGutter,
3865 137 : drawSelection
3866 138 : } from '@codemirror/view';
3867 139 : import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
3868 140 : import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
3869 141 : import {
3870 142 : syntaxHighlighting,
3871 143 : defaultHighlightStyle,
3872 144 : bracketMatching,
3873 145 : indentOnInput,
3874 146 : foldGutter,
3875 147 : foldService,
3876 148 : codeFolding,
3877 149 : foldAll,
3878 150 : unfoldAll,
3879 151 : foldCode,
3880 152 : unfoldCode,
3881 153 : foldable,
3882 154 : foldedRanges,
3883 155 : foldEffect,
3884 156 : unfoldEffect
3885 157 : } from '@codemirror/language';
3886 158 : import { vim, Vim, getCM } from '@replit/codemirror-vim';
3887 159 : import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
3888 160 :
3889 161 : // Helper: Find folded range at a position
3890 162 : function findFoldedRangeAt(state, pos) {
3891 163 : const folded = foldedRanges(state);
3892 164 : let result = null;
3893 165 : folded.between(0, state.doc.length, (from, to) => {
3894 166 : if (pos >= from && pos <= to) {
3895 167 : result = { from, to };
3896 168 : }
3897 169 : });
3898 170 : return result;
3899 171 : }
3900 172 :
3901 173 : // Define vim fold commands using folditall-style region finding
3902 174 : Vim.defineAction('foldAll', (cm) => {
3903 175 : foldAll(cm.cm6);
3904 176 : });
3905 177 :
3906 178 : Vim.defineAction('unfoldAll', (cm) => {
3907 179 : unfoldAll(cm.cm6);
3908 180 : });
3909 181 :
3910 182 : Vim.defineAction('foldCode', (cm) => {
3911 183 : const view = cm.cm6;
3912 184 : const pos = view.state.selection.main.head;
3913 185 : const lineNum = view.state.doc.lineAt(pos).number;
3914 186 :
3915 187 : const foldStart = findContainingFoldStart(view.state, lineNum);
3916 188 : if (foldStart !== null) {
3917 189 : const foldRange = foldable(view.state, foldStart, foldStart);
3918 190 : if (foldRange) {
3919 191 : view.dispatch({
3920 192 : effects: foldEffect.of({ from: foldRange.from, to: foldRange.to })
3921 193 : });
3922 194 : }
3923 195 : }
3924 196 : });
3925 197 :
3926 198 : Vim.defineAction('unfoldCode', (cm) => {
3927 199 : const view = cm.cm6;
3928 200 : const pos = view.state.selection.main.head;
3929 201 : const line = view.state.doc.lineAt(pos);
3930 202 : const lineNum = line.number;
3931 203 :
3932 204 : // First check if we're in a folded range
3933 205 : const foldedRange = findFoldedRangeAt(view.state, pos);
3934 206 : if (foldedRange) {
3935 207 : view.dispatch({
3936 208 : effects: unfoldEffect.of({ from: foldedRange.from, to: foldedRange.to })
3937 209 : });
3938 210 : return;
3939 211 : }
3940 212 :
3941 213 : // Check if there's a fold starting at the end of current line
3942 214 : const foldAtLineEnd = findFoldedRangeAt(view.state, line.to);
3943 215 : if (foldAtLineEnd) {
3944 216 : view.dispatch({
3945 217 : effects: unfoldEffect.of({ from: foldAtLineEnd.from, to: foldAtLineEnd.to })
3946 218 : });
3947 219 : return;
3948 220 : }
3949 221 :
3950 222 : const foldStart = findContainingFoldStart(view.state, lineNum);
3951 223 : if (foldStart !== null) {
3952 224 : const foldStartLine = view.state.doc.lineAt(foldStart);
3953 225 : const foldRange = foldable(view.state, foldStartLine.from, foldStartLine.to);
3954 226 : if (foldRange) {
3955 227 : const folded = foldedRanges(view.state);
3956 228 : let isFolded = false;
3957 229 : folded.between(foldRange.from, foldRange.to, (from) => {
3958 230 : if (from === foldRange.from) isFolded = true;
3959 231 : });
3960 232 : if (isFolded) {
3961 233 : view.dispatch({
3962 234 : effects: unfoldEffect.of({ from: foldRange.from, to: foldRange.to })
3963 235 : });
3964 236 : }
3965 237 : }
3966 238 : }
3967 239 : });
3968 240 :
3969 241 : Vim.defineAction('toggleFold', (cm) => {
3970 242 : const view = cm.cm6;
3971 243 : const pos = view.state.selection.main.head;
3972 244 : const lineNum = view.state.doc.lineAt(pos).number;
3973 245 :
3974 246 : const foldStart = findContainingFoldStart(view.state, lineNum);
3975 247 : if (foldStart === null) return;
3976 248 :
3977 249 : const line = view.state.doc.lineAt(foldStart);
3978 250 : const foldRange = foldable(view.state, line.from, line.to);
3979 251 : if (!foldRange) return;
3980 252 :
3981 253 : const folded = foldedRanges(view.state);
3982 254 : let isFolded = false;
3983 255 : folded.between(foldRange.from, foldRange.to, (from) => {
3984 256 : if (from === foldRange.from) isFolded = true;
3985 257 : });
3986 258 :
3987 259 : if (isFolded) {
3988 260 : view.dispatch({
3989 261 : effects: unfoldEffect.of({ from: foldRange.from, to: foldRange.to })
3990 262 : });
3991 263 : } else {
3992 264 : view.dispatch({
3993 265 : effects: foldEffect.of({ from: foldRange.from, to: foldRange.to })
3994 266 : });
3995 267 : }
3996 268 : });
3997 269 :
3998 270 : // Map vim fold commands
3999 271 : Vim.mapCommand('zc', 'action', 'foldCode', {}, { context: 'normal' });
4000 272 : Vim.mapCommand('zo', 'action', 'unfoldCode', {}, { context: 'normal' });
4001 273 : Vim.mapCommand('za', 'action', 'toggleFold', {}, { context: 'normal' });
4002 274 : Vim.mapCommand('zM', 'action', 'foldAll', {}, { context: 'normal' });
4003 275 : Vim.mapCommand('zR', 'action', 'unfoldAll', {}, { context: 'normal' });
4004 276 : Vim.mapCommand('zr', 'action', 'unfoldAll', {}, { context: 'normal' });
4005 277 : Vim.mapCommand('zm', 'action', 'foldAll', {}, { context: 'normal' });
4006 278 : // Space toggles fold (like za) - folditall behavior
4007 279 : Vim.mapCommand('<Space>', 'action', 'toggleFold', {}, { context: 'normal' });
4008 280 :
4009 281 : // Test content with all folding types
4010 282 : const TEST_CONTENT = `# Level 1 Header - Main Document
4011 283 :
4012 284 : This content is under a level 1 header.
4013 285 :
4014 286 : ## Level 2 - Features
4015 287 :
4016 288 : Content under level 2.
4017 289 :
4018 290 : ### Level 3 - Folding Types
4019 291 :
4020 292 : We support multiple folding types.
4021 293 :
4022 294 : #### Level 4 - Header Folding
4023 295 :
4024 296 : Headers fold everything until the next header of same or higher level.
4025 297 :
4026 298 : ##### Level 5 - Deep Nesting
4027 299 :
4028 300 : This is deeply nested content.
4029 301 :
4030 302 : ###### Level 6 - Maximum Depth
4031 303 :
4032 304 : This is the deepest header level supported.
4033 305 :
4034 306 : Back to level 5 content.
4035 307 :
4036 308 : ##### Level 5 - Another Section
4037 309 :
4038 310 : Another level 5 section.
4039 311 :
4040 312 : #### Level 4 - List Folding
4041 313 :
4042 314 : Lists with children are foldable:
4043 315 :
4044 316 : - Parent item with children
4045 317 : - Child item one
4046 318 : - Child item two
4047 319 : - Grandchild item
4048 320 : - Another grandchild
4049 321 : - Child item three
4050 322 : - Simple item (no children)
4051 323 : - Another parent
4052 324 : - Single child
4053 325 :
4054 326 : Numbered lists also fold:
4055 327 :
4056 328 : 1. First parent
4057 329 : 1. Sub-item one
4058 330 : 2. Sub-item two
4059 331 : 2. Second parent
4060 332 : - Mixed child
4061 333 : - Another mixed
4062 334 :
4063 335 : #### Level 4 - Code Block Folding
4064 336 :
4065 337 : Top-level code blocks fold their contents:
4066 338 :
4067 339 : \`\`\`javascript
4068 340 : function example() {
4069 341 : if (condition) {
4070 342 : doSomething();
4071 343 : }
4072 344 : return result;
4073 345 : }
4074 346 :
4075 347 : class MyClass {
4076 348 : constructor() {
4077 349 : this.x = 1;
4078 350 : }
4079 351 :
4080 352 : method() {
4081 353 : return this.x;
4082 354 : }
4083 355 : }
4084 356 : \`\`\`
4085 357 :
4086 358 : ### Level 3 - Vim Fold Commands
4087 359 :
4088 360 : Test these vim commands (enable vim mode first):
4089 361 :
4090 362 : | Command | Action |
4091 363 : |---------|--------|
4092 364 : | \`za\` | Toggle fold under cursor |
4093 365 : | \`zo\` | Open fold under cursor |
4094 366 : | \`zc\` | Close fold under cursor |
4095 367 : | \`zR\` | Open all folds |
4096 368 : | \`zM\` | Close all folds |
4097 369 : | \`zr\` | Reduce folding (open one level) |
4098 370 : | \`zm\` | More folding (close one level) |
4099 371 :
4100 372 : ## Level 2 - Another Top Section
4101 373 :
4102 374 : This tests that level 2 properly ends the previous level 2 section.
4103 375 :
4104 376 : ### Level 3 - Final Nested
4105 377 :
4106 378 : Final nested content.
4107 379 :
4108 380 : ## Level 2 - Conclusion
4109 381 :
4110 382 : End of test document.
4111 383 : `;
4112 384 :
4113 385 : // Compartments for runtime-reconfigurable extensions
4114 386 : const vimCompartment = new Compartment();
4115 387 :
4116 388 : // ========================================================================
4117 389 : // Folditall Algorithm Helpers
4118 390 : // ========================================================================
4119 391 :
4120 392 : function getHeaderLevel(text) {
4121 393 : const match = text.match(/^(#{1,6})\s+/);
4122 394 : return match ? match[1].length : 0;
4123 395 : }
4124 396 :
4125 397 : function isListItem(text) {
4126 398 : return /^\s*[-*+]\s/.test(text) || /^\s*\d+[.)]\s/.test(text);
4127 399 : }
4128 400 :
4129 401 : function getIndent(text) {
4130 402 : let indent = 0;
4131 403 : for (const char of text) {
4132 404 : if (char === ' ') indent++;
4133 405 : else if (char === '\t') indent += 2;
4134 406 : else break;
4135 407 : }
4136 408 : return indent;
4137 409 : }
4138 410 :
4139 411 : function isBlankLine(text) {
4140 412 : return /^\s*$/.test(text);
4141 413 : }
4142 414 :
4143 415 : function isFencedCodeBlockOpener(text) {
4144 416 : return /^(`{3,}|~{3,})/.test(text.trim());
4145 417 : }
4146 418 :
4147 419 : function findClosingFence(doc, openerLineNum) {
4148 420 : const openerLine = doc.line(openerLineNum);
4149 421 : const openerText = openerLine.text.trim();
4150 422 : const match = openerText.match(/^(`{3,}|~{3,})/);
4151 423 : if (!match) return null;
4152 424 :
4153 425 : const fenceChar = match[1][0];
4154 426 : const fenceLen = match[1].length;
4155 427 : const totalLines = doc.lines;
4156 428 :
4157 429 : for (let i = openerLineNum + 1; i <= totalLines; i++) {
4158 430 : const line = doc.line(i);
4159 431 : const trimmed = line.text.trim();
4160 432 : const closeMatch = trimmed.match(new RegExp(`^${fenceChar}{${fenceLen},}$`));
4161 433 : if (closeMatch) {
4162 434 : return i;
4163 435 : }
4164 436 : }
4165 437 : return null;
4166 438 : }
4167 439 :
4168 440 : function findNextNonBlank(doc, lineNum) {
4169 441 : const totalLines = doc.lines;
4170 442 : for (let i = lineNum + 1; i <= totalLines; i++) {
4171 443 : const line = doc.line(i);
4172 444 : if (!isBlankLine(line.text)) {
4173 445 : return i;
4174 446 : }
4175 447 : }
4176 448 : return null;
4177 449 : }
4178 450 :
4179 451 : function canStartFold(text) {
4180 452 : if (getHeaderLevel(text) > 0) return true;
4181 453 : if (isListItem(text)) return true;
4182 454 : if (isFencedCodeBlockOpener(text)) return true;
4183 455 : if (getIndent(text) === 0 && !isBlankLine(text)) return true;
4184 456 : return false;
4185 457 : }
4186 458 :
4187 459 : function hasFoldableChildren(doc, lineNum) {
4188 460 : const line = doc.line(lineNum);
4189 461 : const text = line.text;
4190 462 :
4191 463 : if (getHeaderLevel(text) > 0) return true;
4192 464 :
4193 465 : if (isFencedCodeBlockOpener(text)) {
4194 466 : return findClosingFence(doc, lineNum) !== null;
4195 467 : }
4196 468 :
4197 469 : const currentIndent = getIndent(text);
4198 470 : const nextNonBlankNum = findNextNonBlank(doc, lineNum);
4199 471 :
4200 472 : if (nextNonBlankNum === null) return false;
4201 473 :
4202 474 : const nextLine = doc.line(nextNonBlankNum);
4203 475 : const nextText = nextLine.text;
4204 476 :
4205 477 : if (getHeaderLevel(nextText) > 0) return false;
4206 478 :
4207 479 : return getIndent(nextText) > currentIndent;
4208 480 : }
4209 481 :
4210 482 : function findFoldEnd(doc, lineNum) {
4211 483 : const line = doc.line(lineNum);
4212 484 : const text = line.text;
4213 485 : const totalLines = doc.lines;
4214 486 : const headerLevel = getHeaderLevel(text);
4215 487 :
4216 488 : if (headerLevel > 0) {
4217 489 : for (let i = lineNum + 1; i <= totalLines; i++) {
4218 490 : const checkLine = doc.line(i);
4219 491 : const checkLevel = getHeaderLevel(checkLine.text);
4220 492 : if (checkLevel > 0 && checkLevel <= headerLevel) {
4221 493 : return i - 1;
4222 494 : }
4223 495 : }
4224 496 : return totalLines;
4225 497 : }
4226 498 :
4227 499 : if (isFencedCodeBlockOpener(text)) {
4228 500 : const closingLine = findClosingFence(doc, lineNum);
4229 501 : return closingLine !== null ? closingLine : lineNum;
4230 502 : }
4231 503 :
4232 504 : const startIndent = getIndent(text);
4233 505 : let lastContentLine = lineNum;
4234 506 :
4235 507 : for (let i = lineNum + 1; i <= totalLines; i++) {
4236 508 : const checkLine = doc.line(i);
4237 509 : const checkText = checkLine.text;
4238 510 :
4239 511 : if (isBlankLine(checkText)) continue;
4240 512 :
4241 513 : if (getHeaderLevel(checkText) > 0) {
4242 514 : return lastContentLine;
4243 515 : }
4244 516 :
4245 517 : const checkIndent = getIndent(checkText);
4246 518 :
4247 519 : if (checkIndent <= startIndent) {
4248 520 : return lastContentLine;
4249 521 : }
4250 522 :
4251 523 : lastContentLine = i;
4252 524 : }
4253 525 :
4254 526 : return lastContentLine;
4255 527 : }
4256 528 :
4257 529 : function findContainingFoldStart(state, lineNum) {
4258 530 : const doc = state.doc;
4259 531 : const currentLine = doc.line(lineNum);
4260 532 : const currentText = currentLine.text;
4261 533 :
4262 534 : if (canStartFold(currentText) && hasFoldableChildren(doc, lineNum)) {
4263 535 : return currentLine.from;
4264 536 : }
4265 537 :
4266 538 : const currentIndent = getIndent(currentText);
4267 539 :
4268 540 : for (let i = lineNum - 1; i >= 1; i--) {
4269 541 : const line = doc.line(i);
4270 542 : const text = line.text;
4271 543 :
4272 544 : if (isBlankLine(text)) continue;
4273 545 :
4274 546 : const lineIndent = getIndent(text);
4275 547 : const headerLevel = getHeaderLevel(text);
4276 548 :
4277 549 : if (headerLevel > 0) {
4278 550 : const foldEnd = findFoldEnd(doc, i);
4279 551 : if (foldEnd >= lineNum) {
4280 552 : return line.from;
4281 553 : }
4282 554 : continue;
4283 555 : }
4284 556 :
4285 557 : if (lineIndent < currentIndent && canStartFold(text) && hasFoldableChildren(doc, i)) {
4286 558 : const foldEnd = findFoldEnd(doc, i);
4287 559 : if (foldEnd >= lineNum) {
4288 560 : return line.from;
4289 561 : }
4290 562 : }
4291 563 : }
4292 564 :
4293 565 : return null;
4294 566 : }
4295 567 :
4296 568 : // Folditall-style fold service
4297 569 : const folditallFoldService = foldService.of((state, lineStart, lineEnd) => {
4298 570 : const doc = state.doc;
4299 571 : const line = doc.lineAt(lineStart);
4300 572 : const text = line.text;
4301 573 : const lineNum = line.number;
4302 574 :
4303 575 : if (isBlankLine(text)) return null;
4304 576 : if (!canStartFold(text)) return null;
4305 577 : if (!hasFoldableChildren(doc, lineNum)) return null;
4306 578 :
4307 579 : const endLineNum = findFoldEnd(doc, lineNum);
4308 580 : if (endLineNum <= lineNum) return null;
4309 581 :
4310 582 : const endLine = doc.line(endLineNum);
4311 583 : return { from: line.to, to: endLine.to };
4312 584 : });
4313 585 :
4314 586 : // Create editor theme
4315 587 : const editorTheme = EditorView.theme({
4316 588 : '&': {
4317 589 : fontSize: '14px',
4318 590 : fontFamily: 'var(--theme-font-mono)',
4319 591 : backgroundColor: 'var(--base01)',
4320 592 : color: 'var(--base05)',
4321 593 : },
4322 594 : '&.cm-focused': {
4323 595 : outline: 'none',
4324 596 : },
4325 597 : '.cm-content': {
4326 598 : padding: '10px 14px',
4327 599 : caretColor: 'var(--base05)',
4328 600 : },
4329 601 : '.cm-cursor, .cm-dropCursor': {
4330 602 : borderLeftColor: 'var(--base05)',
4331 603 : },
4332 604 : '.cm-selectionBackground, ::selection': {
4333 605 : backgroundColor: 'var(--base02)',
4334 606 : },
4335 607 : '.cm-activeLine': {
4336 608 : backgroundColor: 'var(--base02)',
4337 609 : },
4338 610 : '.cm-activeLineGutter': {
4339 611 : backgroundColor: 'var(--base02)',
4340 612 : },
4341 613 : '.cm-gutters': {
4342 614 : backgroundColor: 'var(--base01)',
4343 615 : color: 'var(--base03)',
4344 616 : border: 'none',
4345 617 : borderRight: '1px solid var(--base02)',
4346 618 : },
4347 619 : '.cm-lineNumbers .cm-gutterElement': {
4348 620 : padding: '0 8px 0 4px',
4349 621 : },
4350 622 : '.cm-foldGutter': {
4351 623 : width: '12px',
4352 624 : },
4353 625 : '.cm-header': {
4354 626 : color: 'var(--base0D)',
4355 627 : fontWeight: '600',
4356 628 : },
4357 629 : '.cm-fat-cursor': {
4358 630 : backgroundColor: 'var(--base05) !important',
4359 631 : color: 'var(--base00) !important',
4360 632 : },
4361 633 : '&:not(.cm-focused) .cm-fat-cursor': {
4362 634 : backgroundColor: 'transparent !important',
4363 635 : outline: '1px solid var(--base05)',
4364 636 : },
4365 637 : '.cm-vim-panel': {
4366 638 : padding: '4px 10px',
4367 639 : backgroundColor: 'var(--base00)',
4368 640 : borderTop: '1px solid var(--base02)',
4369 641 : fontFamily: 'var(--theme-font-mono)',
4370 642 : fontSize: '13px',
4371 643 : color: 'var(--base04)',
4372 644 : },
4373 645 : '.cm-vim-panel input': {
4374 646 : backgroundColor: 'transparent',
4375 647 : border: 'none',
4376 648 : outline: 'none',
4377 649 : color: 'var(--base05)',
4378 650 : fontFamily: 'inherit',
4379 651 : fontSize: 'inherit',
4380 652 : },
4381 653 : }, { dark: true });
4382 654 :
4383 655 : // Build extensions
4384 656 : const extensions = [
4385 657 : history(),
4386 658 : drawSelection(),
4387 659 : indentOnInput(),
4388 660 : bracketMatching(),
4389 661 : highlightActiveLine(),
4390 662 : highlightActiveLineGutter(),
4391 663 : highlightSelectionMatches(),
4392 664 :
4393 665 : keymap.of([
4394 666 : ...defaultKeymap,
4395 667 : ...historyKeymap,
4396 668 : ...searchKeymap,
4397 669 : indentWithTab,
4398 670 : ]),
4399 671 :
4400 672 : markdown({ base: markdownLanguage }),
4401 673 : syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
4402 674 :
4403 675 : // Folding support
4404 676 : codeFolding(),
4405 677 : folditallFoldService,
4406 678 : lineNumbers(),
4407 679 : foldGutter(),
4408 680 :
4409 681 : // Theme
4410 682 : editorTheme,
4411 683 :
4412 684 : // Vim mode (starts disabled)
4413 685 : vimCompartment.of([]),
4414 686 :
4415 687 : // Update listener for status
4416 688 : EditorView.updateListener.of(update => {
4417 689 : if (update.selectionSet) {
4418 690 : const pos = update.state.selection.main.head;
4419 691 : const line = update.state.doc.lineAt(pos);
4420 692 : const col = pos - line.from + 1;
4421 693 : document.getElementById('cursor-pos').textContent =
4422 694 : `Cursor: ${line.number}:${col}`;
4423 695 :
4424 696 : // Update vim status line position
4425 697 : const posInfo = document.querySelector('.vim-position-info');
4426 698 : if (posInfo) {
4427 699 : posInfo.textContent = `Ln ${line.number}, Col ${col}`;
4428 700 : }
4429 701 : }
4430 702 :
4431 703 : // Check for vim mode changes
4432 704 : const statusLine = document.getElementById('vim-status-line');
4433 705 : if (statusLine && statusLine.style.display !== 'none') {
4434 706 : try {
4435 707 : const cm = getCM(update.view);
4436 708 : if (cm && cm.state && cm.state.vim) {
4437 709 : const vimState = cm.state.vim;
4438 710 : let currentMode = 'normal';
4439 711 : let modeLabel = 'NORMAL';
4440 712 : let modeColor = 'var(--base0A)';
4441 713 :
4442 714 : if (vimState.insertMode) {
4443 715 : currentMode = 'insert';
4444 716 : modeLabel = '-- INSERT --';
4445 717 : modeColor = 'var(--base0B)';
4446 718 : } else if (vimState.visualMode) {
4447 719 : if (vimState.visualLine) {
4448 720 : currentMode = 'visual-line';
4449 721 : modeLabel = '-- V-LINE --';
4450 722 : } else if (vimState.visualBlock) {
4451 723 : currentMode = 'visual-block';
4452 724 : modeLabel = '-- V-BLOCK --';
4453 725 : } else {
4454 726 : currentMode = 'visual';
4455 727 : modeLabel = '-- VISUAL --';
4456 728 : }
4457 729 : modeColor = 'var(--base0E)';
4458 730 : }
4459 731 :
4460 732 : const modeIndicator = document.querySelector('.vim-mode-indicator');
4461 733 : if (modeIndicator) {
4462 734 : modeIndicator.textContent = modeLabel;
4463 735 : modeIndicator.style.color = modeColor;
4464 736 : }
4465 737 : }
4466 738 : } catch (e) {
4467 739 : // Vim not active
4468 740 : }
4469 741 : }
4470 742 : }),
4471 743 : ];
4472 744 :
4473 745 : // Create editor
4474 746 : const state = EditorState.create({
4475 747 : doc: TEST_CONTENT,
4476 748 : extensions,
4477 749 : });
4478 750 :
4479 751 : const view = new EditorView({
4480 752 : state,
4481 753 : parent: document.getElementById('editor-container'),
4482 754 : });
4483 755 :
4484 756 : // Expose for testing
4485 757 : window.editorView = view;
4486 758 : window.editorState = state;
4487 759 : window.foldAll = () => foldAll(view);
4488 760 : window.unfoldAll = () => unfoldAll(view);
4489 761 :
4490 762 : // Folditall-aware foldCode: folds the containing fold region
4491 763 : window.foldCode = (pos) => {
4492 764 : const lineNum = view.state.doc.lineAt(pos).number;
4493 765 : const foldStart = findContainingFoldStart(view.state, lineNum);
4494 766 : if (foldStart !== null) {
4495 767 : const foldRange = foldable(view.state, foldStart, foldStart);
4496 768 : if (foldRange) {
4497 769 : view.dispatch({
4498 770 : effects: foldEffect.of({ from: foldRange.from, to: foldRange.to })
4499 771 : });
4500 772 : return true;
4501 773 : }
4502 774 : }
4503 775 : return false;
4504 776 : };
4505 777 :
4506 778 : // Folditall-aware unfoldCode
4507 779 : window.unfoldCode = (pos) => {
4508 780 : const line = view.state.doc.lineAt(pos);
4509 781 : const lineNum = line.number;
4510 782 :
4511 783 : // Check if cursor is in a folded range
4512 784 : const foldedRange = findFoldedRangeAt(view.state, pos);
4513 785 : if (foldedRange) {
4514 786 : view.dispatch({
4515 787 : effects: unfoldEffect.of({ from: foldedRange.from, to: foldedRange.to })
4516 788 : });
4517 789 : return true;
4518 790 : }
4519 791 :
4520 792 : // Check if there's a fold starting at the end of current line
4521 793 : const foldAtLineEnd = findFoldedRangeAt(view.state, line.to);
4522 794 : if (foldAtLineEnd) {
4523 795 : view.dispatch({
4524 796 : effects: unfoldEffect.of({ from: foldAtLineEnd.from, to: foldAtLineEnd.to })
4525 797 : });
4526 798 : return true;
4527 799 : }
4528 800 :
4529 801 : const foldStart = findContainingFoldStart(view.state, lineNum);
4530 802 : if (foldStart !== null) {
4531 803 : const foldStartLine = view.state.doc.lineAt(foldStart);
4532 804 : const foldRange = foldable(view.state, foldStartLine.from, foldStartLine.to);
4533 805 : if (foldRange) {
4534 806 : const folded = foldedRanges(view.state);
4535 807 : let isFolded = false;
4536 808 : folded.between(foldRange.from, foldRange.to, (from) => {
4537 809 : if (from === foldRange.from) isFolded = true;
4538 810 : });
4539 811 : if (isFolded) {
4540 812 : view.dispatch({
4541 813 : effects: unfoldEffect.of({ from: foldRange.from, to: foldRange.to })
4542 814 : });
4543 815 : return true;
4544 816 : }
4545 817 : }
4546 818 : }
4547 819 : return false;
4548 820 : };
4549 821 :
4550 822 : window.foldable = (pos) => foldable(view.state, pos, pos);
4551 823 :
4552 824 : window.setVimMode = (enabled) => {
4553 825 : view.dispatch({
4554 826 : effects: vimCompartment.reconfigure(enabled ? vim() : []),
4555 827 : });
4556 828 :
4557 829 : // Show/hide vim status line
4558 830 : const statusLine = document.getElementById('vim-status-line');
4559 831 : if (statusLine) {
4560 832 : statusLine.style.display = enabled ? 'flex' : 'none';
4561 833 : if (enabled) {
4562 834 : const modeIndicator = document.querySelector('.vim-mode-indicator');
4563 835 : if (modeIndicator) {
4564 836 : modeIndicator.textContent = 'NORMAL';
4565 837 : modeIndicator.style.color = 'var(--base0A)';
4566 838 : }
4567 839 : }
4568 840 : }
4569 841 : };
4570 842 :
4571 843 : window.getCursorLine = () => {
4572 844 : const pos = view.state.selection.main.head;
4573 845 : return view.state.doc.lineAt(pos).number;
4574 846 : };
4575 847 :
4576 848 : window.setCursorLine = (lineNum) => {
4577 849 : const line = view.state.doc.line(lineNum);
4578 850 : view.dispatch({
4579 851 : selection: { anchor: line.from },
4580 852 : scrollIntoView: true,
4581 853 : });
4582 854 : view.focus();
4583 855 : };
4584 856 :
4585 857 : window.getFoldedRanges = () => {
4586 858 : // Get all folded ranges from the fold state
4587 859 : const foldState = view.state.field(codeFolding().spec.provides, false);
4588 860 : if (!foldState) return [];
4589 861 : // Note: This is a simplified check - full implementation would iterate decorations
4590 862 : return [];
4591 863 : };
4592 864 :
4593 865 : window.isFolded = (lineNum) => {
4594 866 : const line = view.state.doc.line(lineNum);
4595 867 : // Check if the line is at a fold point and is currently folded
4596 868 : const foldRange = foldable(view.state, line.from, line.to);
4597 869 : if (!foldRange) return false;
4598 870 :
4599 871 : // Check the fold decorations
4600 872 : // A line is considered folded if the next visible line is not lineNum + 1
4601 873 : // This is a heuristic - better would be to check fold state directly
4602 874 : return false; // Placeholder - actual implementation needs fold state access
4603 875 : };
4604 876 :
4605 877 : window.simulateVimCommand = (keys) => {
4606 878 : // Simulate vim keystrokes
4607 879 : view.focus();
4608 880 : for (const key of keys) {
4609 881 : const event = new KeyboardEvent('keydown', {
4610 882 : key: key,
4611 883 : code: `Key${key.toUpperCase()}`,
4612 884 : bubbles: true,
4613 885 : cancelable: true,
4614 886 : });
4615 887 : view.contentDOM.dispatchEvent(event);
4616 888 : }
4617 889 : };
4618 890 :
4619 891 : // UI Controls
4620 892 : document.getElementById('vim-toggle').addEventListener('change', (e) => {
4621 893 : window.setVimMode(e.target.checked);
4622 894 : });
4623 895 :
4624 896 : document.getElementById('fold-all').addEventListener('click', () => {
4625 897 : window.foldAll();
4626 898 : });
4627 899 :
4628 900 : document.getElementById('unfold-all').addEventListener('click', () => {
4629 901 : window.unfoldAll();
4630 902 : });
4631 903 :
4632 904 : // Signal ready
4633 905 : document.body.dataset.ready = 'true';
4634 906 : console.log('[test] Editor ready');
4635 907 : </script>
4636 908 : </body>
4637 909 : </html>
4638Modified regular file tests/fixtures/desktop-app.ts:
4639 ...
4640 198 198: });
4641 199 199:
4642 200 200: // Wait for background window to be ready (API loaded)
4643 201 201: // Use 30s timeout to handle slow launches after previous test cleanup
4644 202 201: const bgWindow = await waitForWindowHelper(() => electronApp.windows(), 'app/background.html', 30000);
4645 203 201: await waitForAppReady(bgWindow, 15000);
4646 202: await waitForAppReady(bgWindow, 10000);
4647 204 203:
4648 205 204: // Hybrid mode: wait for extension host (built-in) AND separate windows (external like 'example')
4649 206 205: const waitForHybridExtensions = async (timeout: number): Promise<void> => {
4650 ...
4651 337 336: if (isRunning(pid)) {
4652 338 337: console.error(`[test] WARNING: Process ${pid} still running after SIGKILL`);
4653 339 338: }
4654 340 :
4655 341 : // Allow extra time for OS to fully release resources (ports, files, etc.)
4656 342 : await sleep(500);
4657 343 339: }
4658 344 340: };
4659 345 341: }
4660 ...