Cookbook PejavaCommander

Практические рецепты для авторов плагинов. Полный справочник — в API. Рабочие версии нескольких рецептов лежат в examples/plugins.

  1. Первый плагин
  2. Команда, клавиша, меню и строка F-клавиш
  3. Новая колонка (поле)
  4. Новый режим отображения
  5. Данные из Node.js (Node-часть и RPC)
  6. Своя панель (провайдер)
  7. Цвета, CSS и палитры
  8. Настройки плагина
  9. Работа с терминалом
  10. Диалоги
  11. Действие над выбранными файлами
  12. Хранение состояния
  13. Замена и изменение встроенного поведения
  14. Упаковка и установка
  15. Для пользователя: клавиши и палитры без кода
  16. Свой вид панели
  17. Details и перетаскивание в своём провайдере

1. Первый плагин

hello/
  plugin.json
  renderer.js

plugin.json:

{
  "id": "me.hello",
  "name": "Hello",
  "version": "0.1.0",
  "renderer": "renderer.js",
  "contributes": {
    "commands": [{ "command": "me.hello", "title": "Say Hello", "category": "Hello" }]
  }
}

renderer.js:

export function activate(pc) {
  pc.commands.register('me.hello', () => pc.ui.showMessage('Привет из моего плагина!'));
}

Запуск без установки (в Windows запустите PejavaCommander.exe, в Linux — AppImage, с тем же параметром):

/Applications/PejavaCommander.app/Contents/MacOS/PejavaCommander --plugin-dev=/path/to/hello

Нажмите Cmd/Ctrl+Shift+P, введите «hello» и нажмите Enter. После правок кода нажмите Cmd/Ctrl+Shift+R, чтобы перезагрузить окно.

2. Команда, клавиша, меню и строка F-клавиш

"contributes": {
  "commands": [
    { "command": "me.touch", "title": "Create Empty File…", "category": "Files", "keybarTitle": "Touch" }
  ],
  "keybindings": [
    { "key": "shift+f4", "command": "me.touch", "when": "panelFocus && activePanelProvider == 'fs'" }
  ],
  "menus": [
    { "menu": "file", "command": "me.touch", "group": "2" }
  ]
}
export function activate(pc) {
  pc.commands.register('me.touch', async () => {
    const name = await pc.ui.showInputBox({ title: 'Create File', prompt: 'Имя файла:' });
    if (!name) return;
    await pc.terminal.run(`touch ${pc.terminal.quote(name)}`, { showOutput: false });
  });
}
  • Строка F-клавиш: зажмите Shift в панелях, и над F4 появится «Touch». Строка всегда показывает привязки F1–F10, действующие прямо сейчас при зажатых модификаторах.
  • when: ограничивайте клавиши условием. С panelFocus F-клавиши продолжают работать в программах терминала, когда панели скрыты.

3. Новая колонка (поле)

Поле с возрастом файла:

export function activate(pc) {
  const DAY = 86_400_000;
  pc.panels.registerField('me.age', {
    title: 'Age',
    width: 5,
    align: 'right',
    render: (item) => (item.mtime && !item.isParent ? `${Math.floor((Date.now() - item.mtime) / DAY)}d` : ''),
    sortValue: (item) => item.mtime ?? 0,
  });
}

Добавьте поле в режим (следующий рецепт). По нему можно и сортировать: Ctrl+F12 → Age.

4. Новый режим отображения

Режим — это обычный JSON, код не нужен:

"panelModes": [
  {
    "id": "me.ages",
    "title": "Name + Age (2 колонки)",
    "columns": 2,
    "providers": ["fs"],
    "fields": [{ "field": "name" }, { "field": "me.age" }]
  }
],
"keybindings": [
  { "key": "ctrl+7", "command": "panels.setMode", "args": "me.ages", "when": "panelFocus" }
]

columns — сколько перетекающих колонок заполняют элементы. fields — что показывает каждая колонка. Режим сам появится в меню Left и Right и в Ctrl+M.

Колонки фиксированной ширины вместо фиксированного числа: "columns": "auto", "columnWidth": 30 (в символах). columnWidth может быть и ключом числовой настройки — так встроенный режим columns использует panels.columnWidth.

5. Данные из Node.js (Node-часть и RPC)

У UI-части нет доступа к Node. Код для Node кладите в main.js и вызывайте через RPC. Полный пример — examples/plugins/git-status.

plugin.json: "main": "main.js", "renderer": "renderer.js"

main.js:

const fs = require('fs/promises');

exports.activate = (context) => {
  // Считаем строки текстового файла.
  context.rpc.handle('countLines', async (file) => {
    const text = await fs.readFile(file, 'utf8');
    return text.split('\n').length;
  });

  // Отправляем события в UI-часть.
  const timer = setInterval(() => context.rpc.emit('tick', Date.now()), 60_000);
  context.subscriptions.push({ dispose: () => clearInterval(timer) });
};

renderer.js:

export function activate(pc) {
  pc.commands.register('me.lines', async () => {
    const item = pc.panels.active.cursorItem;
    if (!item || item.isDir) return;
    const n = await pc.rpc.call('countLines', item.path);
    pc.ui.showMessage(`${item.name}: ${n} строк`);
  });
  pc.rpc.on('tick', (t) => console.log('tick', t));
}

Асинхронные поля: render() должен вернуть значение сразу. Кэшируйте результат, при промахе кэша запускайте загрузку, а когда данные придут, вызывайте panel.refresh(). Так сделано в примере git.

6. Своя панель (провайдер)

Провайдер даёт панели новое назначение, например показ переменных окружения:

export function activate(pc) {
  pc.panels.registerProvider('me.env', {
    title: 'Environment',
    description: 'Переменные окружения',
    defaultMode: 'me.env.list',

    async list() {
      const out = await pc.rpc.call('env'); // в main.js: return process.env
      const items = [{ name: '..', isParent: true, isDir: true }];
      for (const [name, value] of Object.entries(out)) items.push({ name, value });
      return { location: 'env', title: 'Environment', items };
    },

    async open(item, { panel }) {
      if (item.isParent) {
        if (!(await panel.back())) await panel.setProvider('fs');
        return;
      }
      pc.terminal.sendText(`$${item.name}`); // вставить в командную строку
    },

    parentLocation: () => null, // «вверх» = назад, откуда пришли
    statusText: (item) => item.value ?? '',
  });

  pc.panels.registerField('me.env.value', { title: 'Value', width: '*', render: (i) => i.value ?? '' });
  pc.panels.registerMode({
    id: 'me.env.list', title: 'Env', columns: 1, providers: ['me.env'],
    fields: [{ field: 'name', width: 24 }, { field: 'me.env.value' }],
  });
}

Откройте панель через Alt+F1 / Alt+F2 (провайдер есть в списке источников) или из команды: pc.panels.active.setProvider('me.env'). Ещё пример — список избранных директорий в examples/plugins/bookmarks.

7. Цвета, CSS и палитры

Объявите цвета, чтобы пользователь мог менять их в Settings → Colors:

"contributes": {
  "colors": [{ "id": "me.warn.foreground", "default": "#ff5555", "description": "Предупреждения в моей колонке" }]
},
"styles": ["styles.css"]

styles.css (id цвета становится CSS-переменной, точки заменяются дефисами):

.me-warn { color: var(--me-warn-foreground); }

Применение в поле: className: (item) => (item.size > 1e9 ? 'me-warn' : undefined). Чтобы покрасить всю строку, задайте в провайдере item.color = 'me.warn.foreground'.

Плагину с одной палитрой код не нужен. Пример — examples/plugins/solarized-palette:

"contributes": { "palettes": [{ "id": "example-solarized-dark", "label": "Solarized Dark (example)", "path": "solarized-dark.json" }] }

Совет: соберите палитру в Settings → Colors (Duplicate → правка → Export…) и положите в плагин экспортированный файл.

8. Настройки плагина

"contributes": {
  "settings": [
    { "key": "me.maxLines", "type": "number", "default": 1000, "description": "Прекратить подсчёт после N строк" },
    { "key": "me.mode", "type": "string", "enum": ["fast", "exact"], "default": "fast", "description": "Режим подсчёта" }
  ]
}
const max = pc.settings.get('me.maxLines');
pc.settings.onDidChange((keys) => {
  if (keys.includes('me.mode')) reconfigure();
});
await pc.settings.update('me.maxLines', 5000); // или null для сброса

9. Работа с терминалом

// Выполнить команду. Панели скроются до её завершения.
await pc.terminal.run(`du -sh ${pc.terminal.quote(item.name)}`);

// Выполнить тихо, не скрывая панели.
await pc.terminal.run('git fetch', { showOutput: false });

// Напечатать в командную строку, не выполняя.
pc.terminal.sendText(`${pc.terminal.quote(item.path)} `);

// Следить за shell.
pc.terminal.onDidChangeCwd((dir) => console.log('shell теперь в', dir));
pc.terminal.onDidPrompt(() => console.log('команда завершилась'));

// cd (отклоняется, пока работает программа).
const res = await pc.terminal.cd('/tmp');
if (!res.ok) pc.ui.showMessage(`cd не выполнен: ${res.reason}`, 'warning');

10. Диалоги

const pick = await pc.ui.showQuickPick(
  [{ label: 'Zip', description: '.zip', value: 'zip' }, { label: 'Tar', value: 'tar' }],
  { title: 'Формат архива' },
);
if (!pick) return; // Esc

const name = await pc.ui.showInputBox({
  title: 'Архив',
  prompt: 'Имя файла:',
  value: 'backup.zip',
  validate: (v) => (v.trim() ? undefined : 'Обязательно'),
});

const answer = await pc.ui.confirm('Перезаписать файл?', { buttons: ['Overwrite', 'Cancel'], danger: true });
if (answer !== 'Overwrite') return;

pc.ui.showMessage('Готово');           // info
pc.ui.showMessage('Осторожно', 'warning');
pc.ui.showMessage('Ошибка', 'error');

11. Действие над выбранными файлами

targetItems возвращает отмеченные элементы или элемент под курсором, если ничего не отмечено:

pc.commands.register('me.zip', async () => {
  const panel = pc.panels.active;
  if (panel.providerId !== 'fs') return;
  const names = panel.targetItems.map((i) => pc.terminal.quote(i.name)).join(' ');
  if (!names) return;
  await pc.terminal.run(`zip -r archive.zip ${names}`);
  await panel.refresh();
  panel.clearSelection();
});

Клавиши выделения: Insert / Ctrl+T / Shift+↑↓ — отметить, Num+ / Num− — по маске, Num* — инвертировать, Cmd/Ctrl+A — всё.

12. Хранение состояния

export async function activate(pc, context) {
  const count = (await context.state.get('launches', 0)) + 1;
  await context.state.update('launches', count);
}

Для больших данных используйте Node-часть и context.storagePath.

13. Замена и изменение встроенного поведения

  • Переназначить клавиши (без кода): Settings → Keyboard. Изменения сохраняются в keybindings.json.
  • Изменить действие клавиши в определённом контексте: объявите привязку с более конкретным when. Она важнее встроенной:
    { "key": "f3", "command": "me.preview", "when": "panelFocus && activePanelProvider == 'fs' && !cursorIsDirectory" }
  • Заменить встроенный плагин: установите плагин с тем же id, например core.fileops. Он заменит встроенную копию; в менеджере источник будет показан как «installed*».
  • Выключить встроенную функцию: отключите плагин в менеджере (F2), например core.fileops или core.palettes. core.workbench и core.panels отключить нельзя.
  • Изменить действие Enter в файловых панелях: зарегистрируйте свой провайдер с id fs в плагине, который заменяет core.filesystem. Или привяжите enter к своей команде с более конкретным when.

14. Упаковка и установка

cd my-plugin && zip -r ../my-plugin.zip .      # plugin.json в корне архива

Откройте менеджер плагинов (Cmd/Ctrl+Shift+X или Options → Plugin Manager), нажмите F5 Install и выберите:

  • From folder… — копирует папку в <userData>/plugins/<id>;
  • From .zip package…;
  • From URL… — скачивает .zip. Этим будет пользоваться будущий сервер плагинов.

В менеджере: F2 — включить/выключить, F8 — удалить, Enter — подробности, F4 — показать папку, Esc/F10 — закрыть менеджер.

15. Для пользователя: клавиши и палитры без кода

  • F9 открывает палитру команд: Settings → Keyboard, найдите «Command Palette», нажмите +, затем F9.
  • Убрать сочетание: нажмите ✕ на его плашке. Для встроенной клавиши сохранится запись {"key": "...", "command": "-id"}.
  • Поделиться клавишами: Settings → Keyboard → Export… / Import….
  • Свои цвета: Settings → Colors → выберите палитру → Duplicate → правка (главное окно обновляется сразу) → Activate. Поделиться — через Export… / Import….
  • Быстро сменить палитру: Options → Select Color Palette….

16. Свой вид панели

Вид определяет, как рисуется панель. Список, сетка иконок и превью — всё это виды. Этот пример показывает каждый элемент полосой, пропорциональной размеру:

export function activate(pc) {
  pc.panels.registerView('me.sizebars', {
    title: 'Size bars',
    create(container, ctx) {
      const root = document.createElement('div');
      root.style.cssText = 'flex:1; overflow:hidden; padding:0 1ch';
      container.append(root);
      root.addEventListener('mousedown', (e) => {
        const row = e.target.closest('[data-index]');
        if (row) ctx.panel.click(Number(row.dataset.index), e); // выделение через Shift/Cmd работает само
      });
      let top = 0;
      const rows = () => Math.max(1, Math.floor(root.clientHeight / ctx.metrics.rowHeight()));
      return {
        get pageSize() { return rows(); },
        step: (dir) => ({ up: -1, down: 1, pageUp: -rows(), pageDown: rows() })[dir] ?? 0,
        render() {
          const { items, cursorIndex } = ctx.panel;
          const n = rows();
          if (cursorIndex < top) top = cursorIndex;
          if (cursorIndex >= top + n) top = cursorIndex - n + 1;
          const max = Math.max(1, ...items.map((i) => i.size || 0));
          root.replaceChildren(...items.slice(top, top + n).map((item, k) => {
            const row = document.createElement('div');
            row.dataset.index = top + k;
            row.className = `pc-row${top + k === cursorIndex ? ' is-cursor' : ''}${ctx.panel.isSelected(item) ? ' is-selected' : ''}`;
            const pct = Math.round(((item.size || 0) / max) * 100);
            row.style.background = top + k === cursorIndex ? '' :
              `linear-gradient(90deg, color-mix(in srgb, var(--panel-cursor-background) 40%, transparent) ${pct}%, transparent ${pct}%)`;
            row.textContent = item.name;
            return row;
          }));
        },
        dispose: () => root.remove(),
      };
    },
  });
  pc.panels.registerMode({ id: 'me.sizebars', title: 'Size bars', view: 'me.sizebars', providers: ['fs'] });
}

Включается через Ctrl+M или привязкой клавиши к panels.setMode с аргументом "me.sizebars". Советы:

  • Используйте классы ядра (pc-row, is-cursor, is-selected), тогда палитры применятся автоматически.
  • ctx.mode.options передаёт произвольные параметры из описания режима.
  • Для содержимого файлов: pc.fs.fileUrl(path) (img/video/audio), pc.fs.readText(path) и pc.fs.thumbnail(path, size).
  • Вид, который следит за другой панелью (как превью), подписывается через pc.panels.getPanel(otherSide).onDidChange(...) и обязан отписаться в dispose().

17. Details и перетаскивание в своём провайдере

pc.panels.registerProvider('me.notes', {
  title: 'Notes',
  async list() { /* … элементы { name, path, size, mtime } … */ },

  // Блок details под панелью в две колонки (F9 / Ctrl+I делают его компактным: только путь).
  details(item) {
    return [
      ['Note', item.name, { wide: true }],
      ['Words', String(item.words)], ['Updated', new Date(item.mtime).toLocaleString()],
    ];
  },

  // Заметки можно перетащить в файловую панель…
  canDrag: (item) => Boolean(item.path),

  // …а файлы — бросить сюда, чтобы импортировать.
  dropEffect: (drop) => (drop.source?.providerId === 'me.notes' ? 'none' : 'copy'),
  async acceptDrop(drop, { panel }) {
    await pc.rpc.call('import', drop.paths);
    await panel.refresh();
  },
});

Панель находит перетаскиваемые элементы по элементам вида с data-index, поэтому это работает в любом виде: в списке, в иконках и в вашем собственном. Файловая панель, принявшая перетаскивание, вызывает fileops.copyTo / moveTo с path ваших элементов.