Compare commits

..

3 Commits
dev ... main

Author SHA1 Message Date
1d4c3fa904 - теперь терминал открывается сразу в папке проекта 2026-05-15 16:33:56 +03:00
d54e9dd058 - добавлена работа с терминалом по ssh 2026-05-15 13:44:15 +03:00
792ea03808 - поправлена логика сохранения пароля
- изменен алгоритм формирования пути
- добавлена возможность удаления файлов
2026-05-15 11:49:16 +03:00
12 changed files with 404 additions and 146 deletions

View File

@ -14,6 +14,7 @@ dependencies {
// IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html
intellijPlatform { intellijPlatform {
intellijIdeaCommunity("2024.1.7") intellijIdeaCommunity("2024.1.7")
bundledPlugin("org.jetbrains.plugins.terminal")
testFramework(TestFrameworkType.Platform) testFramework(TestFrameworkType.Platform)
} }
} }

View File

@ -11,7 +11,6 @@ import org.jetbrains.annotations.Nullable;
import javax.swing.*; import javax.swing.*;
import java.awt.event.MouseEvent; import java.awt.event.MouseEvent;
import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -36,7 +35,6 @@ public class ChooseServerWidget implements StatusBarWidget,
return this; return this;
} }
// ---------- IconPresentation ----------
@Override @Override
public @Nullable Icon getIcon() { public @Nullable Icon getIcon() {
return null; return null;
@ -76,7 +74,6 @@ public class ChooseServerWidget implements StatusBarWidget,
}; };
} }
// ---------- TextPresentation ----------
@Override @Override
public @Nullable String getText() { public @Nullable String getText() {
String serverId = ProjectServerMapping.getInstance().getServerIdForProject(project.getBasePath()); String serverId = ProjectServerMapping.getInstance().getServerIdForProject(project.getBasePath());
@ -92,12 +89,10 @@ public class ChooseServerWidget implements StatusBarWidget,
return JComponent.CENTER_ALIGNMENT; return JComponent.CENTER_ALIGNMENT;
} }
// ---------- Логика переключения ----------
private void switchToServer(SshServer server) { private void switchToServer(SshServer server) {
SshServerManager.getInstance().setCurrentServerId(server.id); SshServerManager.getInstance().setCurrentServerId(server.id);
String localPath = Paths.get(System.getProperty("user.home"), "ssh-remote-projects", String localPath = SshServerManager.getLocalProjectPath(server);
sanitize(server.host), sanitize(server.remoteProjectPath)).toString().replace('\\', '/');
for (Project openProject : ProjectManager.getInstance().getOpenProjects()) { for (Project openProject : ProjectManager.getInstance().getOpenProjects()) {
if (openProject.getBasePath() != null && openProject.getBasePath().equals(localPath)) { if (openProject.getBasePath() != null && openProject.getBasePath().equals(localPath)) {
@ -118,8 +113,4 @@ public class ChooseServerWidget implements StatusBarWidget,
com.intellij.openapi.options.ShowSettingsUtil.getInstance() com.intellij.openapi.options.ShowSettingsUtil.getInstance()
.showSettingsDialog(project, SshServerConfigurable.class); .showSettingsDialog(project, SshServerConfigurable.class);
} }
private String sanitize(String input) {
return input.replaceAll("[^a-zA-Z0-9.-]", "_");
}
} }

View File

@ -16,33 +16,25 @@ import com.jcraft.jsch.*;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.io.IOException; import java.io.IOException;
import org.jdom.JDOMException; import org.jdom.JDOMException;
import java.io.File;
import java.io.*; import java.io.*;
import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.Optional;
import java.util.Vector; import java.util.Vector;
public class ConnectToRemoteAction extends AnAction { public class ConnectToRemoteAction extends AnAction {
@Override @Override
public void actionPerformed(@NotNull AnActionEvent e) { public void actionPerformed(@NotNull AnActionEvent e) {
// Показываем окно настройки серверов
ShowSettingsUtil.getInstance().showSettingsDialog(e.getProject(), SshServerConfigurable.class); ShowSettingsUtil.getInstance().showSettingsDialog(e.getProject(), SshServerConfigurable.class);
} }
// Статический метод для открытия проекта (используется из виджета и действия)
public static void openProjectForServer(SshServer server) { public static void openProjectForServer(SshServer server) {
String localPath = Paths.get(System.getProperty("user.home"), "ssh-remote-projects", String localPath = SshServerManager.getLocalProjectPath(server);
sanitize(server.host), sanitize(server.remoteProjectPath)).toString().replace('\\', '/');
// Если папка уже существует, просто открываем
File localDir = new File(localPath); File localDir = new File(localPath);
if (localDir.exists() && localDir.listFiles().length > 0) { if (localDir.exists() && localDir.listFiles().length > 0) {
openExistingProject(localPath, server.id); openExistingProject(localPath, server.id);
} else { } else {
// Скачиваем и открываем
downloadAndOpen(localPath, server); downloadAndOpen(localPath, server);
} }
} }
@ -74,6 +66,28 @@ public class ConnectToRemoteAction extends AnAction {
} }
} }
public static void syncProject(SshServer server, String localPath, ProgressIndicator indicator) throws Exception {
JSch jsch = new JSch();
Session session = jsch.getSession(server.user, server.host, server.port);
session.setPassword(SshServerManager.getInstance().getPassword(server.id));
java.util.Properties config = new java.util.Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.connect(5000);
ChannelSftp sftp = (ChannelSftp) session.openChannel("sftp");
sftp.connect(3000);
try {
File localDir = new File(localPath);
if (!localDir.exists()) localDir.mkdirs();
syncDir(sftp, server.remoteProjectPath, localDir, indicator);
} finally {
sftp.disconnect();
session.disconnect();
}
}
private static void downloadProject(ProgressIndicator indicator, String localPath, SshServer server) throws Exception { private static void downloadProject(ProgressIndicator indicator, String localPath, SshServer server) throws Exception {
JSch jsch = new JSch(); JSch jsch = new JSch();
Session session = jsch.getSession(server.user, server.host, server.port); Session session = jsch.getSession(server.user, server.host, server.port);
@ -116,6 +130,49 @@ public class ConnectToRemoteAction extends AnAction {
} }
} }
private static void syncDir(ChannelSftp sftp, String remoteDir, File localDir, ProgressIndicator indicator) {
try {
@SuppressWarnings("unchecked")
Vector<ChannelSftp.LsEntry> entries = sftp.ls(remoteDir);
for (ChannelSftp.LsEntry entry : entries) {
if (indicator.isCanceled()) break;
String filename = entry.getFilename();
if (filename.equals(".") || filename.equals("..")) continue;
String remotePath = remoteDir + "/" + filename;
try {
if (entry.getAttrs().isDir()) {
File subDir = new File(localDir, filename);
if (!subDir.exists()) subDir.mkdirs();
syncDir(sftp, remotePath, subDir, indicator);
} else {
File localFile = new File(localDir, filename);
boolean needDownload = !localFile.exists();
if (!needDownload && localFile.isFile()) {
long remoteSize = entry.getAttrs().getSize();
long remoteMtime = entry.getAttrs().getMTime();
if (localFile.length() != remoteSize || localFile.lastModified() / 1000L != remoteMtime) {
needDownload = true;
}
}
if (needDownload) {
indicator.setText("Syncing: " + filename);
sftp.get(remotePath, localFile.getAbsolutePath());
if (entry.getAttrs().getMTime() > 0) {
localFile.setLastModified(entry.getAttrs().getMTime() * 1000L);
}
}
}
} catch (Exception e) {
System.err.println("Skip entry: " + remotePath + " - " + e.getMessage());
}
}
} catch (SftpException e) {
System.err.println("Cannot list directory: " + remoteDir + " - " + e.getMessage());
}
}
private static void createProjectStructure(File projectDir) throws IOException { private static void createProjectStructure(File projectDir) throws IOException {
File ideaDir = new File(projectDir, ".idea"); File ideaDir = new File(projectDir, ".idea");
if (!ideaDir.exists()) ideaDir.mkdirs(); if (!ideaDir.exists()) ideaDir.mkdirs();
@ -163,75 +220,4 @@ public class ConnectToRemoteAction extends AnAction {
writer.write(content); writer.write(content);
} }
} }
private static String sanitize(String input) {
return input.replaceAll("[^a-zA-Z0-9.-]", "_");
}
public static void syncProject(SshServer server, String localPath, ProgressIndicator indicator) throws Exception {
JSch jsch = new JSch();
Session session = jsch.getSession(server.user, server.host, server.port);
session.setPassword(SshServerManager.getInstance().getPassword(server.id));
java.util.Properties config = new java.util.Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.connect(5000);
ChannelSftp sftp = (ChannelSftp) session.openChannel("sftp");
sftp.connect(3000);
try {
File localDir = new File(localPath);
if (!localDir.exists()) localDir.mkdirs();
syncDir(sftp, server.remoteProjectPath, localDir, indicator);
} finally {
sftp.disconnect();
session.disconnect();
}
}
private static void syncDir(ChannelSftp sftp, String remoteDir, File localDir, ProgressIndicator indicator) {
try {
@SuppressWarnings("unchecked")
Vector<ChannelSftp.LsEntry> entries = sftp.ls(remoteDir);
for (ChannelSftp.LsEntry entry : entries) {
if (indicator.isCanceled()) break;
String filename = entry.getFilename();
if (filename.equals(".") || filename.equals("..")) continue;
String remotePath = remoteDir + "/" + filename;
try {
if (entry.getAttrs().isDir()) {
File subDir = new File(localDir, filename);
if (!subDir.exists()) subDir.mkdirs();
syncDir(sftp, remotePath, subDir, indicator);
} else {
File localFile = new File(localDir, filename);
boolean needDownload = !localFile.exists();
if (!needDownload && localFile.isFile()) {
long remoteSize = entry.getAttrs().getSize();
long remoteMtime = entry.getAttrs().getMTime();
if (localFile.length() != remoteSize || localFile.lastModified() / 1000L != remoteMtime) {
needDownload = true;
}
}
if (needDownload) {
indicator.setText("Syncing: " + filename);
sftp.get(remotePath, localFile.getAbsolutePath());
if (entry.getAttrs().getMTime() > 0) {
localFile.setLastModified(entry.getAttrs().getMTime() * 1000L);
}
}
}
} catch (Exception e) {
// Логируем и пропускаем файл, который не удалось скачать/записать
System.err.println("Skip entry: " + remotePath + " - " + e.getMessage());
}
}
} catch (SftpException e) {
System.err.println("Cannot list directory: " + remoteDir + " - " + e.getMessage());
}
}
} }

View File

@ -0,0 +1,88 @@
package com.example.plugin;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.Messages;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.wm.ToolWindow;
import com.intellij.openapi.wm.ToolWindowManager;
import com.intellij.terminal.JBTerminalWidget;
import com.intellij.ui.content.Content;
import com.intellij.ui.content.ContentFactory;
import com.jcraft.jsch.ChannelShell;
import com.jediterm.terminal.TtyConnector;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.plugins.terminal.JBTerminalSystemSettingsProvider;
import java.util.Optional;
public class OpenRemoteTerminalAction extends AnAction {
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
Project project = e.getProject();
if (project == null) return;
String serverId = ProjectServerMapping.getInstance()
.getServerIdForProject(project.getBasePath());
if (serverId == null) {
Messages.showErrorDialog("No server associated with this project.", "Error");
return;
}
Optional<SshServer> maybeServer =
SshServerManager.getInstance().findServer(serverId);
if (maybeServer.isEmpty()) {
Messages.showErrorDialog("Server not found.", "Error");
return;
}
SshServer server = maybeServer.get();
try {
ChannelShell shellChannel =
SftpSessionManager.getInstance(project)
.getShellChannel(server);
TtyConnector connector = new SshTtyConnector(shellChannel, server.remoteProjectPath);
ToolWindow terminalToolWindow =
ToolWindowManager.getInstance(project).getToolWindow("Terminal");
if (terminalToolWindow == null) {
Messages.showErrorDialog("Terminal tool window not found.", "Error");
return;
}
Disposable disposable =
Disposer.newDisposable(project, "Remote SSH Terminal");
JBTerminalWidget widget =
new JBTerminalWidget(
project,
new JBTerminalSystemSettingsProvider(),
disposable
);
Content content =
ContentFactory.getInstance()
.createContent(widget, "Remote: " + server.name, false);
content.setDisposer(disposable);
terminalToolWindow.getContentManager().addContent(content);
terminalToolWindow.getContentManager().setSelectedContent(content, true);
terminalToolWindow.activate(() -> widget.start(connector), true);
} catch (Exception ex) {
Messages.showErrorDialog(
"Failed to open terminal: " + ex.getMessage(),
"Error"
);
}
}
}

View File

@ -13,7 +13,6 @@ import com.intellij.openapi.vfs.VfsUtil;
import com.intellij.openapi.vfs.VirtualFile; import com.intellij.openapi.vfs.VirtualFile;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.nio.file.Paths;
import java.util.Optional; import java.util.Optional;
public class RefreshFromServerAction extends AnAction { public class RefreshFromServerAction extends AnAction {
@ -42,12 +41,9 @@ public class RefreshFromServerAction extends AnAction {
@Override @Override
public void run(@NotNull ProgressIndicator indicator) { public void run(@NotNull ProgressIndicator indicator) {
try { try {
String localRoot = Paths.get(System.getProperty("user.home"), "ssh-remote-projects", String localRoot = SshServerManager.getLocalProjectPath(server);
sanitize(server.host), sanitize(server.remoteProjectPath))
.toString().replace('\\', '/');
ConnectToRemoteAction.syncProject(server, localRoot, indicator); ConnectToRemoteAction.syncProject(server, localRoot, indicator);
// Обновляем виртуальную файловую систему, чтобы IDE увидела изменения
ApplicationManager.getApplication().invokeLater(() -> { ApplicationManager.getApplication().invokeLater(() -> {
VirtualFile rootDir = LocalFileSystem.getInstance().findFileByPath(localRoot); VirtualFile rootDir = LocalFileSystem.getInstance().findFileByPath(localRoot);
if (rootDir != null) { if (rootDir != null) {
@ -62,8 +58,4 @@ public class RefreshFromServerAction extends AnAction {
} }
}); });
} }
private String sanitize(String input) {
return input.replaceAll("[^a-zA-Z0-9.-]", "_");
}
} }

View File

@ -4,11 +4,11 @@ import com.intellij.openapi.project.Project;
import com.jcraft.jsch.*; import com.jcraft.jsch.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
public class SftpSessionManager { public class SftpSessionManager {
private final ConcurrentHashMap<String, ChannelSftp> channels = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, ChannelSftp> channels = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, ChannelShell> shellChannels = new ConcurrentHashMap<>();
private final Object lock = new Object(); private final Object lock = new Object();
public ChannelSftp getChannel(SshServer server) throws Exception { public ChannelSftp getChannel(SshServer server) throws Exception {
@ -20,32 +20,59 @@ public class SftpSessionManager {
channel = channels.get(server.id); channel = channels.get(server.id);
if (channel != null && channel.isConnected() && !channel.isClosed()) return channel; if (channel != null && channel.isConnected() && !channel.isClosed()) return channel;
JSch jsch = new JSch(); Session session = getSession(server);
Session session = jsch.getSession(server.user, server.host, server.port);
session.setPassword(SshServerManager.getInstance().getPassword(server.id));
java.util.Properties config = new java.util.Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.connect(5000);
ChannelSftp newChannel = (ChannelSftp) session.openChannel("sftp"); ChannelSftp newChannel = (ChannelSftp) session.openChannel("sftp");
newChannel.connect(3000); newChannel.connect(3000);
sessions.put(server.id, session);
channels.put(server.id, newChannel); channels.put(server.id, newChannel);
return newChannel; return newChannel;
} }
} }
public ChannelShell getShellChannel(SshServer server) throws Exception {
ChannelShell shell = shellChannels.get(server.id);
if (shell != null && shell.isConnected() && !shell.isClosed()) {
return shell;
}
synchronized (lock) {
shell = shellChannels.get(server.id);
if (shell != null && shell.isConnected() && !shell.isClosed()) return shell;
Session session = getSession(server);
ChannelShell newShell = (ChannelShell) session.openChannel("shell");
newShell.connect(5000);
shellChannels.put(server.id, newShell);
return newShell;
}
}
private Session getSession(SshServer server) throws JSchException {
Session session = sessions.get(server.id);
if (session == null || !session.isConnected()) {
JSch jsch = new JSch();
session = jsch.getSession(server.user, server.host, server.port);
session.setPassword(SshServerManager.getInstance().getPassword(server.id));
java.util.Properties config = new java.util.Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.connect(5000);
sessions.put(server.id, session);
}
return session;
}
public void disconnect() { public void disconnect() {
synchronized (lock) { synchronized (lock) {
for (ChannelShell sh : shellChannels.values()) {
if (sh.isConnected()) sh.disconnect();
}
shellChannels.clear();
for (ChannelSftp ch : channels.values()) { for (ChannelSftp ch : channels.values()) {
if (ch.isConnected()) ch.disconnect(); if (ch.isConnected()) ch.disconnect();
} }
channels.clear();
for (Session s : sessions.values()) { for (Session s : sessions.values()) {
if (s.isConnected()) s.disconnect(); if (s.isConnected()) s.disconnect();
} }
channels.clear();
sessions.clear(); sessions.clear();
} }
} }

View File

@ -189,6 +189,11 @@ class ServerDialog extends DialogWrapper {
result.add(new ValidationInfo("Invalid port", portField)); result.add(new ValidationInfo("Invalid port", portField));
} }
if (remotePathField.getText().trim().isEmpty()) result.add(new ValidationInfo("Remote path required", remotePathField)); if (remotePathField.getText().trim().isEmpty()) result.add(new ValidationInfo("Remote path required", remotePathField));
// Требуем пароль для нового сервера
if (SshServerManager.getInstance().findServer(server.id).isEmpty() && passwordField.getPassword().length == 0) {
result.add(new ValidationInfo("Password required for new server", passwordField));
}
return result; return result;
} }
@ -206,7 +211,7 @@ class ServerDialog extends DialogWrapper {
if (!pass.isEmpty()) { if (!pass.isEmpty()) {
SshServerManager.getInstance().setPassword(server.id, pass); SshServerManager.getInstance().setPassword(server.id, pass);
} }
// Если поле пустое оставляем старый пароль (если есть) нетронутым // Если пароль пустой и сервер уже существует старый пароль остаётся без изменений
super.doOKAction(); super.doOKAction();
} }
} }

View File

@ -9,6 +9,7 @@ import com.intellij.util.xmlb.XmlSerializerUtil;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -56,7 +57,6 @@ public class SshServerManager implements PersistentStateComponent<SshServerManag
if (id.equals(myState.currentServerId)) { if (id.equals(myState.currentServerId)) {
myState.currentServerId = null; myState.currentServerId = null;
} }
// Удаляем сохранённый пароль
PasswordSafe.getInstance().setPassword(createCredentialAttributes(id), null); PasswordSafe.getInstance().setPassword(createCredentialAttributes(id), null);
} }
@ -103,4 +103,22 @@ public class SshServerManager implements PersistentStateComponent<SshServerManag
return com.intellij.openapi.application.ApplicationManager.getApplication() return com.intellij.openapi.application.ApplicationManager.getApplication()
.getService(SshServerManager.class); .getService(SshServerManager.class);
} }
// ---------- Вспомогательные статические методы ----------
/**
* Возвращает локальный путь к папке проекта на основе хоста и имени сервера.
*/
public static String getLocalProjectPath(SshServer server) {
return Paths.get(System.getProperty("user.home"), "ssh-remote-projects",
sanitize(server.host), sanitize(server.name))
.toString().replace('\\', '/');
}
/**
* Заменяет в строке все символы, кроме букв, цифр, точки и дефиса, на '_'.
*/
public static String sanitize(String input) {
return input.replaceAll("[^a-zA-Z0-9.-]", "_");
}
} }

View File

@ -13,8 +13,8 @@ import com.intellij.openapi.vfs.*;
import com.jcraft.jsch.*; import com.jcraft.jsch.*;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.nio.file.Paths;
import java.util.Optional; import java.util.Optional;
import java.util.Vector;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@ -35,7 +35,7 @@ public class SshSyncStartupActivity implements StartupActivity {
} }
}); });
// Слушатель изменений файлов для синхронизации // Слушатель изменений файлов для синхронизации (создание, изменение, удаление)
VirtualFile baseDir = project.getBaseDir(); VirtualFile baseDir = project.getBaseDir();
if (baseDir != null) { if (baseDir != null) {
VirtualFileManager.getInstance().addVirtualFileListener(new VirtualFileListener() { VirtualFileManager.getInstance().addVirtualFileListener(new VirtualFileListener() {
@ -49,6 +49,11 @@ public class SshSyncStartupActivity implements StartupActivity {
syncFile(event.getFile(), project); syncFile(event.getFile(), project);
} }
@Override
public void fileDeleted(@NotNull VirtualFileEvent event) {
deleteRemoteFile(event.getFile(), project);
}
private void syncFile(VirtualFile file, Project project) { private void syncFile(VirtualFile file, Project project) {
if (!file.isDirectory()) { if (!file.isDirectory()) {
uploadFile(file, project); uploadFile(file, project);
@ -65,18 +70,13 @@ public class SshSyncStartupActivity implements StartupActivity {
Optional<SshServer> maybeServer = SshServerManager.getInstance().findServer(serverId); Optional<SshServer> maybeServer = SshServerManager.getInstance().findServer(serverId);
if (maybeServer.isPresent()) { if (maybeServer.isPresent()) {
SshServer server = maybeServer.get(); SshServer server = maybeServer.get();
// Модальный диалог блокирует IDE до завершения синхронизации
ApplicationManager.getApplication().invokeLater(() -> { ApplicationManager.getApplication().invokeLater(() -> {
ProgressManager.getInstance().run(new Task.Modal(project, "Synchronizing with " + server.name, true) { ProgressManager.getInstance().run(new Task.Modal(project, "Synchronizing with " + server.name, true) {
@Override @Override
public void run(@NotNull ProgressIndicator indicator) { public void run(@NotNull ProgressIndicator indicator) {
try { try {
String localRoot = Paths.get(System.getProperty("user.home"), "ssh-remote-projects", String localRoot = SshServerManager.getLocalProjectPath(server);
sanitize(server.host), sanitize(server.remoteProjectPath))
.toString().replace('\\', '/');
ConnectToRemoteAction.syncProject(server, localRoot, indicator); ConnectToRemoteAction.syncProject(server, localRoot, indicator);
// Обновляем виртуальную файловую систему, чтобы IDE увидела изменения
ApplicationManager.getApplication().invokeLater(() -> { ApplicationManager.getApplication().invokeLater(() -> {
VirtualFile rootDir = LocalFileSystem.getInstance().findFileByPath(localRoot); VirtualFile rootDir = LocalFileSystem.getInstance().findFileByPath(localRoot);
if (rootDir != null) { if (rootDir != null) {
@ -99,7 +99,6 @@ public class SshSyncStartupActivity implements StartupActivity {
String projectPath = project.getBasePath(); String projectPath = project.getBasePath();
if (projectPath == null) return; if (projectPath == null) return;
// Определяем привязанный сервер
String serverId = ProjectServerMapping.getInstance().getServerIdForProject(projectPath); String serverId = ProjectServerMapping.getInstance().getServerIdForProject(projectPath);
if (serverId == null) return; if (serverId == null) return;
@ -107,31 +106,24 @@ public class SshSyncStartupActivity implements StartupActivity {
if (maybeServer.isEmpty()) return; if (maybeServer.isEmpty()) return;
SshServer server = maybeServer.get(); SshServer server = maybeServer.get();
// Вычисляем локальный корень проекта (как он был сохранен при создании) String localRoot = SshServerManager.getLocalProjectPath(server);
String localRoot = Paths.get(System.getProperty("user.home"), "ssh-remote-projects",
sanitize(server.host), sanitize(server.remoteProjectPath))
.toString().replace('\\', '/');
String localFilePath = localFile.getPath(); // всегда с прямыми слешами String localFilePath = localFile.getPath();
// Игнорируем служебные файлы IDE
if (localFilePath.contains("/.idea/") || localFile.getName().equals("remote.iml")) { if (localFilePath.contains("/.idea/") || localFile.getName().equals("remote.iml")) {
return; return;
} }
// Проверяем, что файл принадлежит проекту
if (!localFilePath.startsWith(localRoot)) { if (!localFilePath.startsWith(localRoot)) {
return; return;
} }
// Относительный путь и удалённый путь
String relativePath = localFilePath.substring(localRoot.length()); String relativePath = localFilePath.substring(localRoot.length());
if (relativePath.startsWith("/")) { if (relativePath.startsWith("/")) {
relativePath = relativePath.substring(1); relativePath = relativePath.substring(1);
} }
String remoteFilePath = server.remoteProjectPath + "/" + relativePath; String remoteFilePath = server.remoteProjectPath + "/" + relativePath;
// Отправляем файл в отдельном потоке через общее SFTP-соединение
uploadExecutor.submit(() -> { uploadExecutor.submit(() -> {
try { try {
ChannelSftp sftp = SftpSessionManager.getInstance(project).getChannel(server); ChannelSftp sftp = SftpSessionManager.getInstance(project).getChannel(server);
@ -149,6 +141,84 @@ public class SshSyncStartupActivity implements StartupActivity {
}); });
} }
private void deleteRemoteFile(VirtualFile localFile, Project project) {
String projectPath = project.getBasePath();
if (projectPath == null) return;
String serverId = ProjectServerMapping.getInstance().getServerIdForProject(projectPath);
if (serverId == null) return;
Optional<SshServer> maybeServer = SshServerManager.getInstance().findServer(serverId);
if (maybeServer.isEmpty()) return;
SshServer server = maybeServer.get();
String localRoot = SshServerManager.getLocalProjectPath(server);
String localFilePath = localFile.getPath();
if (localFilePath.contains("/.idea/") || localFile.getName().equals("remote.iml")) {
return;
}
if (!localFilePath.startsWith(localRoot)) {
return;
}
String relativePath = localFilePath.substring(localRoot.length());
if (relativePath.startsWith("/")) {
relativePath = relativePath.substring(1);
}
String remoteFilePath = server.remoteProjectPath + "/" + relativePath;
uploadExecutor.submit(() -> {
try {
ChannelSftp sftp = SftpSessionManager.getInstance(project).getChannel(server);
deleteRemotePath(sftp, remoteFilePath, localFile.isDirectory());
} catch (Exception e) {
ApplicationManager.getApplication().invokeLater(() ->
com.intellij.openapi.ui.Messages.showErrorDialog(
"Delete error " + localFile.getName() + ": " + e.getMessage(),
"SFTP Error"
)
);
}
});
}
private void deleteRemotePath(ChannelSftp sftp, String remotePath, boolean isDirectory) throws Exception {
if (isDirectory) {
try {
Vector<ChannelSftp.LsEntry> entries = sftp.ls(remotePath);
for (ChannelSftp.LsEntry entry : entries) {
String name = entry.getFilename();
if (name.equals(".") || name.equals("..")) continue;
String childPath = remotePath + "/" + name;
if (entry.getAttrs().isDir()) {
deleteRemotePath(sftp, childPath, true);
} else {
try {
sftp.rm(childPath);
} catch (SftpException e) {
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) throw e;
}
}
}
} catch (SftpException e) {
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) throw e;
}
sftp.rmdir(remotePath);
} else {
try {
sftp.rm(remotePath);
} catch (SftpException e) {
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) {
throw e;
}
// иначе файл уже удалён пропускаем
}
}
}
private void createRemoteDirs(ChannelSftp sftp, String dir) throws SftpException { private void createRemoteDirs(ChannelSftp sftp, String dir) throws SftpException {
if (dir.isEmpty() || dir.equals("/")) return; if (dir.isEmpty() || dir.equals("/")) return;
String[] parts = dir.split("/"); String[] parts = dir.split("/");
@ -167,8 +237,4 @@ public class SshSyncStartupActivity implements StartupActivity {
} }
} }
} }
private String sanitize(String input) {
return input.replaceAll("[^a-zA-Z0-9.-]", "_");
}
} }

View File

@ -0,0 +1,89 @@
package com.example.plugin;
import com.jcraft.jsch.ChannelShell;
import com.jediterm.terminal.TtyConnector;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.awt.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
public class SshTtyConnector implements TtyConnector {
private final ChannelShell channel;
private final InputStream inputStream;
private final OutputStream outputStream;
private final Reader reader;
public SshTtyConnector(ChannelShell channel, String initialPath) {
this.channel = channel;
try {
this.inputStream = channel.getInputStream();
this.outputStream = channel.getOutputStream();
this.reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
if (initialPath != null && !initialPath.isBlank()) {
outputStream.write(
("cd \"" + initialPath + "\"\n")
.getBytes(StandardCharsets.UTF_8)
);
outputStream.flush();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public boolean isConnected() {
return channel.isConnected() && !channel.isClosed();
}
@Override
public void close() {
if (channel.isConnected()) {
channel.disconnect();
}
}
@Override
public @Nullable String getName() {
return "SSH Remote";
}
@Override
public int read(char[] buf, int offset, int length) throws IOException {
return reader.read(buf, offset, length);
}
@Override
public void write(String string) throws IOException {
write(string.getBytes(StandardCharsets.UTF_8));
}
@Override
public void write(byte[] bytes) throws IOException {
outputStream.write(bytes);
outputStream.flush();
}
@Override
public void resize(@NotNull Dimension size) {
channel.setPtySize(size.width, size.height, 0, 0);
}
@Override
public int waitFor() throws InterruptedException {
while (isConnected()) {
Thread.sleep(100);
}
return channel.getExitStatus();
}
@Override
public boolean ready() throws IOException {
return reader.ready();
}
}

View File

@ -48,11 +48,8 @@ public class SwitchServerAction extends AnAction {
private void switchToServer(SshServer server, Project currentProject) { private void switchToServer(SshServer server, Project currentProject) {
SshServerManager.getInstance().setCurrentServerId(server.id); SshServerManager.getInstance().setCurrentServerId(server.id);
String localPath = Paths.get(System.getProperty("user.home"), "ssh-remote-projects", String localPath = SshServerManager.getLocalProjectPath(server);
sanitize(server.host), sanitize(server.remoteProjectPath))
.toString().replace('\\', '/');
// Проверяем, открыт ли уже проект с этим путём
for (Project openProject : ProjectManager.getInstance().getOpenProjects()) { for (Project openProject : ProjectManager.getInstance().getOpenProjects()) {
if (openProject.getBasePath() != null && openProject.getBasePath().equals(localPath)) { if (openProject.getBasePath() != null && openProject.getBasePath().equals(localPath)) {
ApplicationManager.getApplication().invokeLater(() -> ApplicationManager.getApplication().invokeLater(() ->
@ -61,15 +58,10 @@ public class SwitchServerAction extends AnAction {
} }
} }
// Закрываем текущий проект и открываем новый
if (currentProject != null && !currentProject.isDisposed()) { if (currentProject != null && !currentProject.isDisposed()) {
ProjectManager.getInstance().closeProject(currentProject); ProjectManager.getInstance().closeProject(currentProject);
} }
ConnectToRemoteAction.openProjectForServer(server); ConnectToRemoteAction.openProjectForServer(server);
} }
private String sanitize(String input) {
return input.replaceAll("[^a-zA-Z0-9.-]", "_");
}
} }

View File

@ -28,15 +28,14 @@
]]> ]]>
</description> </description>
<depends>com.intellij.modules.platform</depends> <depends>com.intellij.modules.platform</depends>
<depends>org.jetbrains.plugins.terminal</depends>
<extensions defaultExtensionNs="com.intellij"> <extensions defaultExtensionNs="com.intellij">
<projectConfigurable instance="com.example.plugin.SshServerConfigurable" <projectConfigurable instance="com.example.plugin.SshServerConfigurable"
displayName="SSH Remote Project"/> displayName="SSH Remote Project"/>
<applicationService serviceImplementation="com.example.plugin.SshServerManager"/> <applicationService serviceImplementation="com.example.plugin.SshServerManager"/>
<applicationService serviceImplementation="com.example.plugin.ProjectServerMapping"/> <applicationService serviceImplementation="com.example.plugin.ProjectServerMapping"/>
<projectService serviceImplementation="com.example.plugin.SftpSessionManager"/> <projectService serviceImplementation="com.example.plugin.SftpSessionManager"/>
<applicationService serviceImplementation="com.example.plugin.ProjectMappingService"/>
<postStartupActivity implementation="com.example.plugin.SshSyncStartupActivity"/> <postStartupActivity implementation="com.example.plugin.SshSyncStartupActivity"/>
</extensions> </extensions>
@ -61,6 +60,10 @@
class="com.example.plugin.RemoteCommandAction" class="com.example.plugin.RemoteCommandAction"
text="Run Remote Command" text="Run Remote Command"
description="Execute command on the associated server"/> description="Execute command on the associated server"/>
<action id="SshRemote.OpenTerminal"
class="com.example.plugin.OpenRemoteTerminalAction"
text="Open Remote Terminal"
description="Open integrated terminal connected to the remote server"/>
<add-to-group group-id="ToolsMenu" anchor="last"/> <add-to-group group-id="ToolsMenu" anchor="last"/>
</group> </group>
</actions> </actions>