Пишем plugin для Jetbrains IDE: отображение файлов вне проекта в Project Tool

от автора

Доброго времени суток, дорогой читатель. Меня зовут Михаил, я Android-разработчик в компании Циан. Этой статьёй я открываю для себя цикл статей по внутренней кухне разработки плагинов для Jetbrains IDE: IDEA, Android Studio (AS) и пр. На дворе 2024 год, официальная документация не так богата информацией, как хотелось бы. Но есть исходники, которые смело можно дербанить. В этом цикле статей я буду описывать свой опыт поиска нужной мне информации и её разбор.

В статье мы разберёмся с тем, как отобразить каталог не из проекта в project tool. 

Я занимаюсь разработкой плагина «Group File Template ​(GFT)», GFT на Market, GitHub В своей предыдущей статье я описал, как им пользоваться. Весь мой опыт работы с плагинами крутится вокруг него. Поэтому в своём блоге буду рассказывать, как я делал тот или иной функционал этого плагина.

Итак, приступим. Плагин GFT я использую как Android-разработчик. Что это значит? Я работаю с проектом, который лежит в одном репозитории — в нём пишут свой код все Android-разработчики. Мы все имеем одну точку синхронизации — ветку develop. Поэтому, при актуальном develop разработчик имеет актуальные шаблоны для GFT.

Посмотрев статистику по плагину, можно увидеть, что его скачивают не только для AS, но и для других Jetbrains IDE.

Таких скачиваний немного, но и плагин не очень полезный для работы с большим количеством репозиторий. Допустим, у вас 100 репозиториев с проектами, одинаковой архитектурой и синтаксисом. Концепция «содержать шаблоны внутри проекта» для вас крайне не удобная. Ведь нужно содержать 100 одинаковых шаблонов в актуальном состоянии. Звучит как подвиг, достойный Геракла.

А значит, надо научить плагин сохранять шаблоны внутри IDE. Тогда можно будет держать шаблоны в одном месте.

Проблему обозначили, накидаем MVP.

План MVP:

  1. При клике правой кнопкой мыши на шаблон GFT, в контекстном меню появляются кнопки «Copy Template» и «Move Template»

  2. Перемещаем или копируем шаблон в каталог с IDE

  3. При копировании даём возможность переименовать шаблон

  4. Отображаем наши шаблоны в project tool.

План небольшой, фича простая. Ну, часа 4 работы, — подумал я и приступил.

День первый. Что такое каталог IDE?

На часах 20:10, классика, приступим. Создаем Action для перемещения шаблонов — MigrateTemplateAction : Action(). Нам нужны 2 кнопочки, значит регистрируем наш Action в plugin.xml 2 раза, с разным именем и ID.

<action        id="MigrateTemplateActionCopy"        class="com.arch.temp.actions.MigrateTemplateAction"        text="Copy Template">    <add-to-group group-id="ProjectViewPopupMenu" anchor="after" relative-to-action="CutCopyPasteGroup"/> </action>  <action        id="MigrateTemplateActionRemove"        class="com.arch.temp.actions.MigrateTemplateAction"        text="Move Template">    <add-to-group group-id="ProjectViewPopupMenu" anchor="after" relative-to-action="MigrateTemplateActionCopy"/> </action>

Мне показалось логичным запихнуть оба Action в блок — CutCopyPasteGroup.

В Action делаем так, чтобы наши кнопки показывались только при вызове контекстного меню на папке с шаблоном. Для этого переопределяем метод update, пишем наше условие и вуаля — всё работает!

override fun update(e: AnActionEvent) {    val project = e.project    val pathTemplate = e.getData(CommonDataKeys.VIRTUAL_FILE)?.path    val shortMainTemplate = File("$pathTemplate/$MAIN_SHORT_FILE_TEMPLATE")    val shirtTemplate = File("$pathTemplate/$MAIN FILE TEMPLATE")    e.presentation.isEnabledAndVisible = project != null && (shortMainTemplate.isFile || shortTemplate.isFile) }

Посмотрел на часы и подумал: «Пфф. Чёт, походу, быстрее сделаю».

Теперь давайте реализуем в нашем Action метод actionPerformed(actionEvent: AnActionEvent). Выглядит всё просто: мы нажимаем на «Copy Template» или «Move Template» и метод вызывается. 

Берём из actionEvent.getData(CommonDataKeys.VIRTUAL_FILE) VirtualFile каталога, по которому мы кликнули. Отлично! Первый путь, указывающий откуда перемещать, у нас есть. Далее, берём путь указывающий куда перемещать. Так, стоп, а откуда его брать? Что такое каталог в IDE?

Начинаем отвечать на вопросы:

  • Что такое каталог IDE? Звучит как то, где лежат конфиг файлы самой IDE. Хотя тут я испытываю сомнения.

  • Откуда брать? Ну, наверное, должен быть какой-нибудь путь, не на уровне project, а на уровне application.

Не понятно. Ладно. Значит что? Правильно, ставим брейкпоинт и начинаем шерстить всё, что примерно может соответствовать нашим вопросам. На часах 00:30. 

Нашёл чуть больше, чем ничего. Пару прикольных методов, например, File.separatorChar, он отдает разделитель в зависимости от OS компа. Полезно, но не то, что я искал. 

Лааааадно. Иду в документацию, почти ничего не нахожу. Всё не то. На часах 01:20. Мы проиграли эту битву, пора спать.

День второй. Scratch files.

Ночью мне не приснилось гениальное решение. Поэтому, шерстю форумы, блоги и натыкаюсь на Scratch files

Scratch-файлы — это полнофункциональные, работоспособные и отлаживаемые файлы, которые поддерживают подсветку синтаксиса, завершение кода и все другие функции для соответствующего типа файлов. Например, во время работы над одним проектом у вас может возникнуть идея метода, который позже можно будет использовать в другом проекте. Вы можете создать рабочий файл с черновиком метода, который не хранится в каталоге вашего проекта, но может быть открыт при работе над любым другим проектом.

Ура! Первая стоящая зацепка! Это же то, что я хочу сделать, ну прям точь-в-точь. Раньше я думал, что это Scratch-файлы — это временные файлы. Всякую требуху туда кидал: JSON из запроса покрутить, main функцию по быстрому вызвать. А оказывается, я миксером шурупы кручу.

Если Scratch-файлы не хранятся в нашем проекте, значит они хранятся где-то во внешнем каталоге. Начинаем наш сёрч, на часах 20:15. Тапаем дважды на Shift, вбиваем Scratch.

Находим 2 объекта: ScratchFileService и ScratchTreeStructureProvider. Заходим в ScratchFileService и находим метод getRootPath.

public @SystemIndependent @NotNull String getRootPath(@NotNull RootType rootType) {        return myRootPaths.get(rootType.getId()); }

Что мы видим: myRootPaths — это ConcurrentMap<String, String> myRootPaths = ConcurrentFactoryMap.createMap(ScratchFileServiceImpl::calcRootPath), из которого по rootType.Id вытаскивается путь. Лезем в calcRootPath.

private static @SystemIndependent @NotNull String calcRootPath(@NotNull String rootId) {  String path = System.getProperty(PathManager.PROPERTY_SCRATCH_PATH + "/" + rootId);  if (path != null && path.length() > 2 && path.charAt(0) == '\"') {        path = StringUtil.unquoteString(path);  }  return path != null ? FileUtil.toSystemIndependentName(path) :        FileUtil.toSystemIndependentName(PathManager.getScratchPath()) + "/" + rootId; }

Что видно? По каким-то свойствам, мы получаем путь к root-каталогу. PROPERTY_SCRATCH_PATH = «idea.scratch.path»

Свойства не обычные, к ним плюсуется какой-то rootId. К тому же, как подкаталог. 

Находим ScratchRootType extends RootType. RootType в конструкторе принимает myId и myDisplayName.

ScratchRootType() {       super("scratches", LangBundle.message("root.type.scratches")); }

RootId получен — это «scratches». Ещё мы видим, что ScratchRootType зарегистрирован в plugin.xml, значит надо не забыть сделать также.

<scratch.rootType implementation="com.intellij.ide.scratch.ScratchRootType"/>

У RootType находим метод getAllRootTypes(). Он возвращает все зарегистрированные реализации RootType.

Кайф, получается я могу отнаследоваться от RootType, зарегистрировать его — и у меня будет путь к моему каталогу с шаблонами. На часах 21:20 первые строки кода понеслись.

Создаю GFTemplateRootType: RootType(ROOT_ID, GFT_ROOT_NAME)и регистрирую его в plugin.xml.

<extensions defaultExtensionNs="com.intellij">    <scratch.rootType implementation="com.arch.temp.extension.GFTemplateRootType"/> </extensions>

Запускаю, вызываю getAllRootTypes(), вижу в списке GFTemplateRootType. Теперь для получения пути позаимствуем метод у ScratchFileServiceImpl.getInstance().getRootPath(rootType). Получаю путь указывающий куда класть шаблоны. Победа!!! Мы знаем точку А и точку Б.

Реализую логику в MigrateTemplateAction, делаю разделение по тексту кнопки. И внимательный читатель в недоумении «Серьезно? По тексту? У нас же есть уникальные ID!» Прикинь, я так и не нашёл, как из Action вытащить его ID (если ты что-то об этом знаешь, беги писать в комменты, добрый человек), это ещё час потраченный в пустую. Перед перемещением вызываем диалог для переименования шаблона.

И собственно всё — наши шаблоны сохраняются в IDE. Но мы пока не видим их в списке шаблонов. Понеслась! Добавляю в ListTemplateActionGroup дополнительный root каталог, в котором мы проверяем наличие шаблонов.

fun AnActionEvent.getListTemplate(): List<MainClassJson> {    val listTemplate = mutableListOf<MainClassJson>()      getData(CommonDataKeys.PROJECT)?.let {        val basePath = getBasePathTemplate()        listTemplate.addAll(getListMainClassTemplate(basePath))    }    val pathHomeTemplate = getRootPathTemplate()    listTemplate.addAll(getListMainClassTemplate(pathHomeTemplate))    return listTemplate }

Запуск и проверка.

Всё работает и даже создаёт всё корректно. На часах 00:40, награждаем себя шоколадкой и спать.

День третий/четвертый/пятый. База. 

Большую часть сделал. Остались пустяки — показать шаблоны в project tool. Все звёзды сложились так, что у Scratch файлов есть свой раздел.

Просто беру и всё, что есть в ScratchTreeStructureProvider, копирую себе. «Обмазываемся» брейкпоинтами, начинаем ювелирить: тут отрезать, сюда приделать, тут переделать. Что-то не работает, берём куски из других мест. Не буду томить, выдаю «Базу» как нам законнектиться с project tool.

Создаём класс TemplateProjectView и наследуемся от AbstractProjectViewPane или AbstractProjectViewPaneWithAsyncSupport.

Регистрируем TemplateProjectView класс в plugin.xml в разделе extension.

<extensions defaultExtensionNs="com.intellij">    <projectViewPane implementation="com.arch.temp.projectView.TemplateProjectView"/> </extensions>

Переопределяем методы и разбираем.

override fun getTitle() = GFT_HOME_TEMPLATES \\ Имя на ProjectViewPane  override fun getIcon() = AllIcons.General.ProjectTab \\ Иконка  override fun getId() = TEMPLATE_VIEW_ID \\ ID по которому можем узнать статус панели и обращаться к ней  override fun getWeight() = 111 \\ Расположение в выпадающем списке чем больше число тем ниже панель

Теперь мы можем увидеть пункт «GFT Templates» в выпадающем меню.

Идём дальше. Когда из списка мы выбираем наше ProjectView, вызывается createTree. Затем createStructure. Я это интерпретировал так, что вначале мы создаём канвас, а затем накидываем на него структуру.

override fun createStructure(): ProjectAbstractTreeStructureBase {    return ProjectViewPaneTreeStructure() }  override fun createTree(treeModel: DefaultTreeModel): ProjectViewTree {    return object : ProjectViewTree(treeModel) {        override fun toString(): String {            return title + " " + super.toString()        }    } }

Реализуем нашу структуру.

inner class ProjectViewPaneTreeStructure : ProjectTreeStructure(myProject, id), ProjectViewSettings {    override fun createRoot(project: Project, settings: ViewSettings): AbstractTreeNode<*> {        return TemplateViewProjectNode(project, settings)    }     override fun isShowLibraryContents() = false  }

Возвращаем в методе isShowLibraryContents false, для того чтобы не показывало вкладку с либами.

Есть похожие методы, они по умолчанию false: isShowScratchesAndConsoles, isShowModules и т. д.

Метод createRoot требует от нас реализацию некой AbstractTreeNode, из которой и состоит вся структура.

В AbstractTreeNode хранится вся необходимая информация для структуры: имя, цвет, иконка, её наследники (если это директория) и всё, всё, всё, что отвечает за отображение элемента.

Реализуем свою ProjectViewNode, для этого переопределяем следующие методы:

  • canRepresent, contains. Срабатывают при нажатии SelectOpenedFile

  • update. Обновляет представление ноды getChildren(): List<AbstractTreeNode<*>>. Здесь пишем всю логику того, что мы хотим показать.

Я решил накостылить и завёл два RootType: один это rootPath, второй projectPath (ну вот захотелось так, для единообразия). Я их получаю через RootType.getAllRootTypes(), по ID. 

В итоге у нас получается два пункта в структуре: Ide GFTemplates и Project GFTemplates.

Наполняем их, вытаскиваем путь. Это pathProject/template и rootPath/template.

Находим VirtualFile и рекурсивно пробежимся по дочерним элементам. Если это файл, то вернем PsiFileNode, если каталог, то PsiDirectoryNode.

Вот и всё. В структуре мы видим все наше дерево с файлами шаблонов из разных мест. УРА!

Тестируем. Для этого создаём новый шаблон. К несчастью, он не появился в структуре. Пробуем переходить по вкладкам — и неожиданно он появляется. Беда, структура не обновляется самостоятельно, а значит, не делает её принудительное обновление.

В главной ноде при вызове метода init регистрируем слушатель на обновления виртуальных файлов.

VirtualFileManager.getInstance().addAsyncFileListener({ events: List<VFileEvent?>? ->    val update = JBIterable.from(events)        .find { e: VFileEvent? ->            ProgressManager.checkCanceled()            val parent = getNewParent(e!!)            if (TemplateUtils.isTemplate(parent)) return@find true            if (!isDirectory(e)) return@find false            false        } != null    if (!update) null else object : AsyncFileListener.ChangeApplier {        override fun afterVfsChange() {            onUpdate.run()        }    } }, project)

Пишу логику на отслеживание того, что в директориях происходят изменения. Обновляем отображение. Запуск. Создаем шаблон — появился, фух! Кликаем на него и копируем, появляется копия в каталоге «Ide GFTemplates».

Кликаем в место где хотим создать шаблон, смотрим список, он отобразился, DONE!

Выводы:

Можно было не использовать ScratchFileServiceImpl и rootType? Скорей всего да, если сесть и разобраться, как зарегистрировать своё свойство и потом обращаться к нему из системы, то легко. Но, в погоне за скоростью реализации и слабым погружением в эту тему, имеем что имеем.

Возможно есть ещё какая-нибудь реализация и она удобнее. Я не нашёл, но чувствую, что есть.

Слабая документация рождает интерес достичь рабочей программы из кусков примера, который скрыт от понимания, а также вдохновляет делиться этим необычным опытом с вами. Мне понравилось! Да и фича вышла полезной. 

Кстати, если посмотреть доку по projectViewPane из кода, то можно увидеть, кто ещё его использовал.

Мы увидим чуть больше 10 плагинов. Теперь и с моим на борту =)

Надеюсь, моя статья была для тебя полезна. Не поленись поставить лайк и написать приятный коммент. Если есть неприятный, тоже пиши — обсудим. Удачи, не болей!

З.Ы. Если твой плагин работает с перемещением / копированием / созданием каталогов или файлов, лучше делать это через Virtual Files. Project tool подписан на их изменения, если использовать Java IO API, то отображение будет обновляться с задержкой. 


ссылка на оригинал статьи https://habr.com/ru/articles/816149/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *