reslop 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ const files = require('../files.js');
9
9
  const { itemPath, isTodoItem, listPath, isReadOnlyOrigin } = files;
10
10
  const { blockAddText } = require('../diff/diff.js');
11
11
  const items = require('./items.js');
12
- const { itemFeedKey, canEditCode, blockLinesKey } = items;
12
+ const { itemFeedKey, canEditCode } = items;
13
13
  const review = require('../review.js');
14
14
  const { setFeedback, setCode, checkLabel, rememberTemplate } = review;
15
15
  const { createTemplatePick } = require('./templates.js');
@@ -81,6 +81,7 @@ const initialState = () => ({
81
81
  blinkOn: true,
82
82
  fileDirty: false,
83
83
  fileEditRows: null,
84
+ fileOpened: '',
84
85
  });
85
86
 
86
87
  const reset = (state) => {
@@ -124,6 +125,7 @@ const openCompose = (api, kind, text, todoId) => {
124
125
  state.composeKind = kind;
125
126
  state.composeTodoId = todoId ?? null;
126
127
  state.composeBaseline = text;
128
+ state.fileOpened = kind === 'file' ? text : '';
127
129
  state.editor = new Editor(text);
128
130
  state.templateIndex = -1;
129
131
  state.templateFocus = false;
@@ -140,6 +142,7 @@ const closeCompose = (api) => {
140
142
  state.commitKind = null;
141
143
  state.composeTodoId = null;
142
144
  state.composeBaseline = '';
145
+ state.fileOpened = '';
143
146
  state.templateIndex = -1;
144
147
  state.templateFocus = false;
145
148
  state.fileEditRows = null;
@@ -240,60 +243,66 @@ const saveUnitFile = (api, text) => {
240
243
  return true;
241
244
  };
242
245
 
243
- const snapshotPathKeys = (list, rel) => {
244
- const keys = new Set();
245
- for (const item of list) {
246
- if (listPath(item) !== rel) continue;
247
- keys.add(blockLinesKey(item));
248
- }
249
- return keys;
250
- };
251
-
252
- const stageNewFileHunks = (api, rel, beforeKeys) => {
246
+ const stageEditedFile = (api, rel) => {
247
+ if (!rel) return false;
253
248
  const repo = api.ui.repo;
254
- if (!rel || !repo || typeof repo.add !== 'function') return 0;
249
+ if (!repo) return false;
255
250
  const caps = api.ui.capabilities;
256
- if (!caps || caps.changes !== true) return 0;
257
- const list = api.ui.items ?? [];
251
+ if (!caps || caps.changes !== true) return false;
252
+ if (typeof repo.stagePath === 'function') {
253
+ try {
254
+ repo.stagePath(api.ui.top, rel);
255
+ } catch (error) {
256
+ api.ui.status = error.message;
257
+ return false;
258
+ }
259
+ if (typeof api.ui.ignoreWatch === 'function') api.ui.ignoreWatch();
260
+ return true;
261
+ }
262
+ if (typeof repo.add !== 'function') return false;
258
263
  let staged = 0;
259
- for (const item of list) {
264
+ for (const item of api.ui.items ?? []) {
260
265
  if (listPath(item) !== rel) continue;
261
266
  if (isReadOnlyOrigin(item.origin)) continue;
262
267
  if (item.origin === 'staged') continue;
263
- if (beforeKeys.has(blockLinesKey(item))) continue;
264
268
  try {
265
269
  repo.add(api.ui.top, item);
266
270
  } catch (error) {
267
271
  api.ui.status = error.message;
268
- return staged;
272
+ return staged > 0;
269
273
  }
270
274
  if (api.ui.collection && api.ui.collection.keepOrigin) {
271
275
  api.ui.collection.keepOrigin(item, 'staged');
272
276
  }
273
- if (typeof api.ui.ignoreWatch === 'function') api.ui.ignoreWatch();
274
277
  staged += 1;
275
278
  }
276
- return staged;
279
+ if (staged && typeof api.ui.ignoreWatch === 'function') {
280
+ api.ui.ignoreWatch();
281
+ }
282
+ return staged > 0;
277
283
  };
278
284
 
279
285
  const saveFileCompose = (api) => {
280
286
  const rel = api.nav.reviewPath;
281
- const beforeKeys = snapshotPathKeys(api.ui.items ?? [], rel);
287
+ const opened = api.state.fileOpened;
288
+ const text = api.state.editor ? api.state.editor.text : '';
282
289
  if (api.state.editor) {
283
290
  api.nav.unitLine = api.state.editor.linePos().line;
284
291
  }
285
- const result = saveUnitFile(api, api.state.editor.text);
292
+ const result = saveUnitFile(api, text);
286
293
  if (result === false) return { kind: 'error' };
294
+ const changed = text !== opened;
287
295
  const reload = api.state.fileDirty === true;
296
+ if (changed) stageEditedFile(api, rel);
288
297
  api.ui.flushReview();
289
298
  closeCompose(api);
299
+ const doneStatus = changed ? 'staged' : 'saved';
290
300
  if (!reload || typeof api.ui.reloadAfterChange !== 'function') {
291
- api.ui.status = 'saved';
301
+ if (!api.ui.status) api.ui.status = doneStatus;
292
302
  return { kind: 'saved' };
293
303
  }
294
304
  api.ui.reloadAfterChange(() => {
295
- const n = stageNewFileHunks(api, rel, beforeKeys);
296
- api.ui.status = n > 0 ? 'staged' : 'saved';
305
+ if (!api.ui.status) api.ui.status = doneStatus;
297
306
  });
298
307
  return { kind: 'saved' };
299
308
  };
@@ -321,6 +330,13 @@ const saveCompose = (api, extra) => {
321
330
  if (spec && spec.command) return { kind: spec.command };
322
331
  const kind = api.state.composeKind;
323
332
  if (kind === 'file') return saveFileCompose(api);
333
+ const item = api.ui.current ? api.ui.current() : null;
334
+ const shouldStage =
335
+ kind === 'code' &&
336
+ writesCodeFile(api, item) &&
337
+ item &&
338
+ item.origin !== 'staged';
339
+ const rel = itemPath(item);
324
340
  const baseline = api.state.composeBaseline;
325
341
  const result = commitCompose(api);
326
342
  if (result === false) return { kind: 'error' };
@@ -331,12 +347,17 @@ const saveCompose = (api, extra) => {
331
347
  const next = editor ? editor.text : '';
332
348
  if (notes) rememberTemplate(notes, baseline, next);
333
349
  }
350
+ if (shouldStage && reload) stageEditedFile(api, rel);
334
351
  api.ui.flushReview();
335
352
  closeCompose(api);
353
+ const doneStatus = shouldStage && reload ? 'staged' : 'saved';
336
354
  if (reload && typeof api.ui.reloadAfterChange === 'function') {
337
- api.ui.reloadAfterChange();
355
+ api.ui.reloadAfterChange(() => {
356
+ if (!api.ui.status) api.ui.status = doneStatus;
357
+ });
358
+ } else {
359
+ api.ui.status = doneStatus;
338
360
  }
339
- api.ui.status = 'saved';
340
361
  if (opts.editNext === true) api.todos.editNextTodo();
341
362
  return { kind: 'saved' };
342
363
  };
@@ -369,7 +390,7 @@ const composeView = (api) => {
369
390
  const kind = state.composeKind;
370
391
  const text = state.editor.text;
371
392
  const cursor = state.editor.cursor;
372
- return { kind, text, cursor };
393
+ return { kind, text, cursor, commitKind: state.commitKind };
373
394
  };
374
395
 
375
396
  const liveCodeText = (api) => {
@@ -32,6 +32,7 @@ const createDispatcher = (ui) => {
32
32
  next: ui.filesPane.onNext,
33
33
  prev: ui.filesPane.onPrev,
34
34
  layout: ui.filesPane.onLayout,
35
+ theme: ui.filesPane.onTheme,
35
36
  files: ui.filesPane.onFiles,
36
37
  feedback: ui.composer.onFeedback,
37
38
  todo: ui.composer.onTodo,
@@ -39,6 +40,8 @@ const createDispatcher = (ui) => {
39
40
  reload: ui.lifecycle.onReload,
40
41
  commit: ui.commits.onCommit,
41
42
  amend: ui.commits.onAmend,
43
+ reword: ui.commits.onReword,
44
+ apply: ui.commits.onApply,
42
45
  fixup: ui.commits.onFixup,
43
46
  branch: ui.gitBranches.onBranch,
44
47
  pull: ui.gitBranches.onPull,
@@ -54,6 +57,7 @@ const createDispatcher = (ui) => {
54
57
  open: ui.filesPane.onOpenFile,
55
58
  file: ui.filesPane.onFileScope,
56
59
  diff: ui.filesPane.onDiffScope,
60
+ check: () => ui.composer.toggleFocusedTodo(),
57
61
  removeTodo: ui.composer.removeFocusedTodo,
58
62
  quit: () => ui.onQuit(),
59
63
  scrollUp: () => ui.filesPane.onScroll(-1),
@@ -142,6 +146,8 @@ const createInput = (ui) => {
142
146
  return void ui.runComposeCommand(command);
143
147
  }
144
148
  if (key === 'escape') return void ui.filesPane.onEscape();
149
+ const toggle = key === 'x' || key === ' ';
150
+ if (toggle && ui.composer.toggleFocusedTodo()) return;
145
151
  if (ui.composer.typeIntoTodo(key)) return;
146
152
  const action = actionFromKey(key, ui.pane, ui.fileScope);
147
153
  if (action) ui.dispatch(action);
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const ansi = require('../ansi.js');
3
4
  const files = require('../files.js');
5
+ const { THEME_NAMES, setTheme, themeName } = ansi;
4
6
  const { isTodosEntry, TODO_FILE } = files;
5
7
  const unit = require('./unit.js');
6
8
  const { setFileScope, openUnitFile, moveUnitBlock } = unit;
@@ -24,10 +26,14 @@ const syncFileCursor = (ui) => {
24
26
  };
25
27
 
26
28
  const showFiles = (ui) => {
27
- ui.nav.pane = 'files';
28
- ui.nav.clearSelection();
29
+ const nav = ui.nav;
30
+ const held =
31
+ nav.todoOpen === true || nav.pane === 'branches' || nav.pane === 'commits';
32
+ nav.pane = 'files';
33
+ nav.clearSelection();
29
34
  followReviewPath(ui);
30
- ui.nav.todoOpen = false;
35
+ nav.todoOpen = false;
36
+ if (held) ui.lifecycle.catchUpWatch();
31
37
  };
32
38
 
33
39
  const onEscape = (ui) => {
@@ -214,6 +220,13 @@ const onLayout = (ui) => {
214
220
  ui.nav.clearSelection();
215
221
  };
216
222
 
223
+ const onTheme = (ui) => {
224
+ const i = THEME_NAMES.indexOf(themeName());
225
+ const next = THEME_NAMES[(i + 1) % THEME_NAMES.length];
226
+ setTheme(next);
227
+ ui.status = next;
228
+ };
229
+
217
230
  const createFilesPane = (ui) => ({
218
231
  clampIndex: () => clampIndex(ui),
219
232
  syncReviewPath: () => syncReviewPath(ui),
@@ -230,6 +243,7 @@ const createFilesPane = (ui) => ({
230
243
  onHome: () => onJump(ui, false),
231
244
  onEnd: () => onJump(ui, true),
232
245
  onLayout: () => onLayout(ui),
246
+ onTheme: () => onTheme(ui),
233
247
  onFileScope: () => setFileScope(ui, 'file'),
234
248
  onDiffScope: () => setFileScope(ui, 'diff'),
235
249
  });
@@ -6,10 +6,11 @@ const path = require('node:path');
6
6
  const { isAbortError } = require('./load.js');
7
7
  const { createDiskWatcher, POLL_MS, DEBOUNCE_MS } = require('./watch.js');
8
8
  const files = require('../files.js');
9
- const { itemPath, relativeAge } = files;
10
- const { MANIFEST, LOCKFILE } = require('../deps.js');
9
+ const { itemPath, relativeAge, TODO_FILE } = files;
10
+ const { MANIFEST, LOCKFILE, depFileMeta } = require('../deps.js');
11
11
  const review = require('../review.js');
12
- const { hasNotes } = review;
12
+ const { hasNotes, loadReview } = review;
13
+ const { mergeReview } = require('../review-merge.js');
13
14
 
14
15
  const loadOptions = (api, extra = {}) => {
15
16
  const { loadConfig, ui } = api;
@@ -196,11 +197,32 @@ const fileStamp = (top, rel) => {
196
197
  }
197
198
  };
198
199
 
200
+ const npmRels = (items) => {
201
+ const rels = [MANIFEST, LOCKFILE];
202
+ for (const item of items) {
203
+ const rel = itemPath(item);
204
+ if (!depFileMeta(rel)) continue;
205
+ if (rels.includes(rel)) continue;
206
+ rels.push(rel);
207
+ }
208
+ rels.sort();
209
+ return rels;
210
+ };
211
+
199
212
  const npmStamp = (api) => {
200
213
  const top = api.ui.top || api.loadConfig.cwd;
201
- const pkg = fileStamp(top, MANIFEST);
202
- const lock = fileStamp(top, LOCKFILE);
203
- return `${pkg}|${lock}`;
214
+ const rels = npmRels(api.collection.items);
215
+ const parts = new Array(rels.length);
216
+ for (let i = 0; i < rels.length; i++) {
217
+ parts[i] = `${rels[i]}:${fileStamp(top, rels[i])}`;
218
+ }
219
+ return parts.join('|');
220
+ };
221
+
222
+ const npmExtrasStale = (api) => {
223
+ const cached = api.npmExtras;
224
+ if (!cached || !api.loadConfig.audit) return false;
225
+ return cached.stamp !== npmStamp(api);
204
226
  };
205
227
 
206
228
  const cacheNpmExtras = (api, loaded) => {
@@ -251,7 +273,6 @@ const followExtras = async (api, loaded, gen, kind) => {
251
273
  const npmReuseOpts = (api, extra = {}) => {
252
274
  const opts = {};
253
275
  if (extra.signal) opts.signal = extra.signal;
254
- if (extra.keepView !== true) return opts;
255
276
  const cached = api.npmExtras;
256
277
  if (!cached) return opts;
257
278
  if (cached.stamp !== npmStamp(api)) return opts;
@@ -314,7 +335,7 @@ const runSnapshot = async (api, gen, kind, extra = {}) => {
314
335
  }
315
336
  applyLoaded(api, api.loader.tag(loaded, gen));
316
337
  afterSnapshot(api, kind, extra);
317
- if (loaded.pending) {
338
+ if (loaded.pending === true || npmExtrasStale(api)) {
318
339
  api.ops.setBusy('checking npm');
319
340
  if (kind === 'open' || extra.quiet === true) api.ui.paint();
320
341
  await followExtras(api, loaded, gen, kind);
@@ -360,7 +381,7 @@ const refreshFromRepo = (api, extra = {}) => {
360
381
  );
361
382
  applyLoaded(api, api.loader.tag(loaded, gen));
362
383
  afterSnapshot(api, kind, extra);
363
- if (loaded.pending) {
384
+ if (loaded.pending === true || npmExtrasStale(api)) {
364
385
  api.ops.setBusy('checking npm');
365
386
  return void followExtras(api, loaded, gen, kind);
366
387
  }
@@ -436,12 +457,14 @@ const onDiskChange = (api) => {
436
457
  refreshFromRepo(api, { keepView: true, quiet: true });
437
458
  };
438
459
 
439
- const currentFileStamp = (api) => {
440
- const pane = api.nav.pane;
441
- if (pane !== 'diff' && pane !== 'unit') return '';
442
- const rel = pane === 'unit' ? api.nav.reviewPath : itemPath(api.ui.current());
443
- if (!rel) return '';
444
- const full = path.join(api.ui.top || api.loadConfig.cwd, rel);
460
+ const catchUpWatch = (api) => {
461
+ clearWatchRetry(api);
462
+ if (shouldDeferWatch(api)) return void onDiskChange(api);
463
+ refreshFromRepo(api, { keepView: true, quiet: true });
464
+ };
465
+
466
+ const stampFile = (root, rel) => {
467
+ const full = path.join(root, rel);
445
468
  let stat;
446
469
  try {
447
470
  stat = fs.statSync(full);
@@ -451,6 +474,30 @@ const currentFileStamp = (api) => {
451
474
  return `${rel}:${stat.mtimeMs}:${stat.size}`;
452
475
  };
453
476
 
477
+ const worktreeStamp = (api) => {
478
+ const root = api.ui.top || api.loadConfig.cwd;
479
+ const parts = [];
480
+ for (const rel of itemRels(api.collection)) {
481
+ if (!rel || rel === TODO_FILE) continue;
482
+ parts.push(stampFile(root, rel));
483
+ }
484
+ return parts.join('\n');
485
+ };
486
+
487
+ const watchesWorktree = (nav) =>
488
+ nav.todoOpen === true || nav.pane === 'branches' || nav.pane === 'commits';
489
+
490
+ const currentFileStamp = (api) => {
491
+ const { nav } = api;
492
+ const root = api.ui.top || api.loadConfig.cwd;
493
+ if (watchesWorktree(nav)) return worktreeStamp(api);
494
+ const pane = nav.pane;
495
+ if (pane !== 'diff' && pane !== 'unit') return '';
496
+ const rel = pane === 'unit' ? nav.reviewPath : itemPath(api.ui.current());
497
+ if (!rel) return '';
498
+ return stampFile(root, rel);
499
+ };
500
+
454
501
  const pollCurrentFile = (api) => {
455
502
  const stamp = currentFileStamp(api);
456
503
  if (!stamp) {
@@ -466,9 +513,46 @@ const pollCurrentFile = (api) => {
466
513
  onDiskChange(api);
467
514
  };
468
515
 
516
+ const reviewFileStamp = (api) => {
517
+ const store = api.review && api.review.store;
518
+ if (!store || !store.reviewPath) return '';
519
+ try {
520
+ const stat = fs.statSync(store.reviewPath);
521
+ return `${stat.mtimeMs}:${stat.size}`;
522
+ } catch {
523
+ return 'missing';
524
+ }
525
+ };
526
+
527
+ const noteReviewStamp = (api) => {
528
+ api.reviewStamp = reviewFileStamp(api);
529
+ };
530
+
531
+ const applyReviewChange = (api) => {
532
+ if (!api.ui || api.ui.done) return;
533
+ const stamp = reviewFileStamp(api);
534
+ if (!stamp || stamp === api.reviewStamp) return;
535
+ if (stamp === 'missing') {
536
+ api.reviewStamp = stamp;
537
+ return;
538
+ }
539
+ const store = api.review && api.review.store;
540
+ if (!store) return;
541
+ let disk;
542
+ try {
543
+ disk = loadReview(store.reviewPath, store.templates);
544
+ } catch {
545
+ return;
546
+ }
547
+ mergeReview(store, disk);
548
+ api.reviewStamp = stamp;
549
+ if (typeof api.ui.paint === 'function') api.ui.paint();
550
+ };
551
+
469
552
  const stopWatch = (api) => {
470
553
  clearWatchRetry(api);
471
554
  api.watchStamp = '';
555
+ api.reviewStamp = '';
472
556
  if (api.ui.term) api.ui.term.stopTimer('watch');
473
557
  if (!api.diskWatcher) return;
474
558
  api.diskWatcher.close();
@@ -484,16 +568,26 @@ const startWatch = (api) => {
484
568
  root,
485
569
  paths: api.loadConfig.paths,
486
570
  onChange: () => onDiskChange(api),
571
+ onReview: () => applyReviewChange(api),
487
572
  });
488
573
  syncItemWatches(api);
574
+ noteReviewStamp(api);
489
575
  if (!api.ui.term) return;
490
- api.ui.term.startTimer('watch', () => pollCurrentFile(api), POLL_MS);
576
+ api.ui.term.startTimer(
577
+ 'watch',
578
+ () => {
579
+ pollCurrentFile(api);
580
+ applyReviewChange(api);
581
+ },
582
+ POLL_MS,
583
+ );
491
584
  };
492
585
 
493
586
  const createLifecycle = (api) => {
494
587
  api.diskWatcher = null;
495
588
  api.watchRetry = null;
496
589
  api.watchStamp = '';
590
+ api.reviewStamp = '';
497
591
  api.ignoreWatchUntil = 0;
498
592
  api.npmExtras = null;
499
593
  return {
@@ -505,6 +599,9 @@ const createLifecycle = (api) => {
505
599
  reloadAfterChange: (afterLoad) => reloadAfterChange(api, afterLoad),
506
600
  onReload: () => onReload(api),
507
601
  onDiskChange: () => onDiskChange(api),
602
+ applyReviewChange: () => applyReviewChange(api),
603
+ noteReviewStamp: () => noteReviewStamp(api),
604
+ catchUpWatch: () => catchUpWatch(api),
508
605
  pollCurrentFile: () => pollCurrentFile(api),
509
606
  startWatch: () => startWatch(api),
510
607
  stopWatch: () => stopWatch(api),
@@ -105,7 +105,10 @@ const RELEASE_TARGETS = [
105
105
  {
106
106
  match: (ui) => ui.pane === 'diff',
107
107
  hits: 'todoHits',
108
- onHit: (ui, hit) => ui.composer.onTodoHit(hit.cursor),
108
+ onHit: (ui, hit) => {
109
+ if (hit.check) return void ui.composer.onTodoCheck(hit.cursor);
110
+ ui.composer.onTodoHit(hit.cursor);
111
+ },
109
112
  },
110
113
  {
111
114
  match: (ui) => ui.mode === 'compose',
@@ -3,7 +3,7 @@
3
3
  const files = require('../files.js');
4
4
  const { isTodosEntry, TODO_FILE } = files;
5
5
  const review = require('../review.js');
6
- const { addTodo, setTodoText, removeTodo, checkLabel } = review;
6
+ const { addTodo, setTodoText, setTodoDone, removeTodo, checkLabel } = review;
7
7
 
8
8
  const TODO_TYPE_SKIP = [
9
9
  'enter',
@@ -101,6 +101,7 @@ const closeTodoPage = (api) => {
101
101
  api.nav.todoOpen = false;
102
102
  api.ui.syncReviewPath();
103
103
  api.ui.syncFileCursor();
104
+ api.ui.lifecycle.catchUpWatch();
104
105
  };
105
106
 
106
107
  const focusTodoPage = (api, todoId) => {
@@ -189,6 +190,17 @@ const removeFocusedTodo = (api) => {
189
190
  api.ui.flushReview(true);
190
191
  };
191
192
 
193
+ const toggleFocusedTodo = (api) => {
194
+ const nav = api.nav;
195
+ if (!nav.todoOpen || nav.pane !== 'diff') return false;
196
+ if (api.ui.mode !== 'review') return false;
197
+ const todo = listTodos(api)[clampedTodoFocus(api)];
198
+ if (!todo) return false;
199
+ setTodoDone(api.review.store, todo.id, !todo.done);
200
+ api.ui.flushReview(true);
201
+ return true;
202
+ };
203
+
192
204
  const onTodoHit = (api, cursor) => {
193
205
  if (!api.nav.todoOpen) return;
194
206
  const todos = listTodos(api);
@@ -206,6 +218,20 @@ const onTodoHit = (api, cursor) => {
206
218
  editFocusedTodo(api);
207
219
  };
208
220
 
221
+ const onTodoCheck = (api, cursor) => {
222
+ if (!api.nav.todoOpen) return;
223
+ const todo = listTodos(api)[cursor];
224
+ if (!todo) return void onTodoHit(api, cursor);
225
+ const { state } = api;
226
+ if (state.composeKind === 'todo' && state.composeTodoId !== todo.id) {
227
+ api.commit();
228
+ api.close();
229
+ }
230
+ setTodoDone(api.review.store, todo.id, !todo.done);
231
+ api.nav.todoFocus = cursor;
232
+ api.ui.flushReview(true);
233
+ };
234
+
209
235
  const createTodo = (api) => {
210
236
  startDraftCompose(api);
211
237
  };
@@ -253,6 +279,8 @@ const createTodoCompose = (api) => ({
253
279
  typeIntoTodo: (key) => typeIntoTodo(api, key),
254
280
  removeFocusedTodo: () => removeFocusedTodo(api),
255
281
  onTodoHit: (cursor) => onTodoHit(api, cursor),
282
+ onTodoCheck: (cursor) => onTodoCheck(api, cursor),
283
+ toggleFocusedTodo: () => toggleFocusedTodo(api),
256
284
  onTodo: () => onTodo(api),
257
285
  todoEditView: () => todoEditView(api),
258
286
  commitTodo: (text) => commitTodo(api, text),