mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-05 10:34:13 +02:00
Live mode: open a saved project, switch between open projects
open_project opens a .qet and makes it current, as File > Open does but with no file dialog and no error windows: a missing, unreadable or foreign file is refused in the answer. A project already open is only made current. switch_project makes another open project current, by its index or its file. status now lists every open project (index, title, file, folios, unsaved changes, current), so the assistant sees what it can switch to. MCP: qet_live_open_project, qet_live_switch_project; both paths go through the workspace policy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+55
-1
@@ -4101,6 +4101,29 @@ def tool_live_new_project(title: str = "", folios: int = 1, path: str = "") -> d
|
||||
return _live_call(request)
|
||||
|
||||
|
||||
def tool_live_open_project(path: str) -> dict:
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("'path' must be the project's file, e.g. /home/me/projects/pump.qet")
|
||||
p = Path(path).expanduser()
|
||||
if not p.is_absolute():
|
||||
raise ValueError("'path' must be absolute, e.g. /home/me/projects/pump.qet")
|
||||
return _live_call({"cmd": "open_project", "path": str(p)})
|
||||
|
||||
|
||||
def tool_live_switch_project(index: int | None = None, path: str = "") -> dict:
|
||||
if (index is None) == (not path):
|
||||
raise ValueError("give exactly one of 'index' (from qet_live_status's "
|
||||
"\"projects\") or 'path'")
|
||||
if index is not None:
|
||||
if not isinstance(index, int) or isinstance(index, bool) or index < 0:
|
||||
raise ValueError("'index' must be a whole number counted from 0")
|
||||
return _live_call({"cmd": "switch_project", "index": index})
|
||||
p = Path(path).expanduser()
|
||||
if not p.is_absolute():
|
||||
raise ValueError("'path' must be absolute")
|
||||
return _live_call({"cmd": "switch_project", "path": str(p)})
|
||||
|
||||
|
||||
def tool_live_undo_last() -> dict:
|
||||
_require_script_consent()
|
||||
return _live_call({"cmd": "undo_last"})
|
||||
@@ -5107,7 +5130,9 @@ TOOLS = [
|
||||
"name": "qet_live_status",
|
||||
"description": "LIVE MODE. Ask the QElectroTech the user has open what is on "
|
||||
"screen: the project, the folio shown (index and title), the "
|
||||
"selected elements, the last undo step and the stored scripts. "
|
||||
"selected elements, the last undo step, the stored scripts, and "
|
||||
"\"projects\": every project open in the window (index, title, "
|
||||
"file, folios, unsaved changes, which is current). "
|
||||
"Works only if the user switched live mode on in QElectroTech "
|
||||
"and accepted its warning at this start; the error says which "
|
||||
"step is missing. Changes nothing.",
|
||||
@@ -5205,6 +5230,33 @@ TOOLS = [
|
||||
"handler": lambda a: tool_live_new_project(a.get("title", ""), a.get("folios", 1),
|
||||
a.get("path", "")),
|
||||
},
|
||||
{
|
||||
"name": "qet_live_open_project",
|
||||
"description": "LIVE MODE. Open a saved project (.qet, absolute path) in the "
|
||||
"QElectroTech the user has open and make it current, as File > "
|
||||
"Open does but with no dialog; errors come back as text. A "
|
||||
"project already open is only made current. Every following "
|
||||
"qet_live_* call works on it.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "absolute .qet path"}},
|
||||
"required": ["path"],
|
||||
},
|
||||
"handler": lambda a: tool_live_open_project(a["path"]),
|
||||
},
|
||||
{
|
||||
"name": "qet_live_switch_project",
|
||||
"description": "LIVE MODE. Make another project that is already open the "
|
||||
"current one, by its index in qet_live_status's \"projects\" or "
|
||||
"by its file path. Every following qet_live_* call works on it. "
|
||||
"Changes no project.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"index": {"type": "integer", "minimum": 0},
|
||||
"path": {"type": "string"}},
|
||||
},
|
||||
"handler": lambda a: tool_live_switch_project(a.get("index"), a.get("path", "")),
|
||||
},
|
||||
{
|
||||
"name": "qet_live_undo_last",
|
||||
"description": "LIVE MODE. Undo the newest step in the open project, only if "
|
||||
@@ -5342,6 +5394,8 @@ _DATA_PATHS = {
|
||||
# QElectroTech writes this file, but where is the client's choice:
|
||||
# held to the same workspace as every other file a tool writes.
|
||||
"qet_live_new_project": {"write": ("path",)},
|
||||
"qet_live_open_project": {"read": ("path",)},
|
||||
"qet_live_switch_project": {"read": ("path",)},
|
||||
"qet_element_build": {"write": ("output",)},
|
||||
# The scripts folder is chosen by scripts_dir(), never by the client,
|
||||
# so only the project a script is tried on is a data path here.
|
||||
|
||||
@@ -184,7 +184,8 @@ class ToolRegistry(unittest.TestCase):
|
||||
"qet_live_run_stored", "qet_live_command", "qet_live_show_folio",
|
||||
"qet_live_undo_last", "qet_live_screenshot", "qet_about",
|
||||
"qet_recording_list", "qet_recording_read", "qet_recording_check",
|
||||
"qet_recording_remove", "qet_layout_check", "qet_live_new_project"})
|
||||
"qet_recording_remove", "qet_layout_check", "qet_live_new_project",
|
||||
"qet_live_open_project", "qet_live_switch_project"})
|
||||
|
||||
|
||||
class EditValidation(unittest.TestCase):
|
||||
@@ -3562,6 +3563,25 @@ class LiveClient(unittest.TestCase):
|
||||
m.tool_live_new_project(path="pump.qet")
|
||||
self.assertEqual(len(self.seen), 2)
|
||||
|
||||
def test_open_and_switch_send_only_what_was_given(self):
|
||||
self.session()
|
||||
m.tool_live_open_project("/tmp/x/pump.qet")
|
||||
m.tool_live_switch_project(1)
|
||||
m.tool_live_switch_project(path="/tmp/x/pump.qet")
|
||||
self.assertEqual([{k: v for k, v in r.items() if k not in ("token", "id")}
|
||||
for r in self.seen],
|
||||
[{"cmd": "open_project", "path": "/tmp/x/pump.qet"},
|
||||
{"cmd": "switch_project", "index": 1},
|
||||
{"cmd": "switch_project", "path": "/tmp/x/pump.qet"}])
|
||||
for bad in ({}, {"index": 0, "path": "/a.qet"}, {"index": -1},
|
||||
{"index": True}, {"path": "rel.qet"}):
|
||||
with self.assertRaises(ValueError):
|
||||
m.tool_live_switch_project(**bad)
|
||||
for bad in ("", "rel.qet"):
|
||||
with self.assertRaises(ValueError):
|
||||
m.tool_live_open_project(bad)
|
||||
self.assertEqual(len(self.seen), 3)
|
||||
|
||||
def test_new_project_needs_script_consent(self):
|
||||
self.session()
|
||||
with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}):
|
||||
|
||||
@@ -256,6 +256,10 @@ void LiveServer::handle(const QJsonObject &request)
|
||||
answer = command(request.value(QStringLiteral("action")).toString());
|
||||
} else if (cmd == QLatin1String("new_project")) {
|
||||
answer = newProject(request);
|
||||
} else if (cmd == QLatin1String("open_project")) {
|
||||
answer = openProject(request.value(QStringLiteral("path")).toString());
|
||||
} else if (cmd == QLatin1String("switch_project")) {
|
||||
answer = switchProject(request);
|
||||
} else if (cmd == QLatin1String("show_folio")) {
|
||||
answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1));
|
||||
} else if (cmd == QLatin1String("undo_last")) {
|
||||
@@ -318,6 +322,7 @@ QJsonObject LiveServer::status()
|
||||
{QStringLiteral("name"), s.header.name}});
|
||||
answer.insert(QStringLiteral("stored_scripts"), scripts);
|
||||
//So an assistant can notice a recording the user just made
|
||||
answer.insert(QStringLiteral("projects"), openProjects());
|
||||
answer.insert(QStringLiteral("macro_recorder"), QJsonObject{
|
||||
{QStringLiteral("recording"), MacroRecorder::instance().isRecording()},
|
||||
{QStringLiteral("steps"), MacroRecorder::instance().stepCount()},
|
||||
@@ -502,6 +507,95 @@ QJsonObject LiveServer::newProject(const QJsonObject &request)
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief LiveServer::openProjects
|
||||
The projects open in the editor window, in tab order: the index
|
||||
switch_project takes, title, file, folios, unsaved changes, and which
|
||||
one is current.
|
||||
*/
|
||||
QJsonArray LiveServer::openProjects() const
|
||||
{
|
||||
QJsonArray list;
|
||||
QETDiagramEditor *e = editor();
|
||||
if (!e) return list;
|
||||
const QETProject *current = e->currentProject();
|
||||
const QList<ProjectView *> views = e->openedProjects();
|
||||
for (int i = 0; i < views.count(); ++i) {
|
||||
QETProject *p = views.at(i)->project();
|
||||
list.append(QJsonObject{
|
||||
{QStringLiteral("index"), i},
|
||||
{QStringLiteral("title"), p->title()},
|
||||
{QStringLiteral("path"), p->filePath()},
|
||||
{QStringLiteral("folios"), int(p->diagrams().count())},
|
||||
{QStringLiteral("modified"), p->projectWasModified()},
|
||||
{QStringLiteral("read_only"), p->isReadOnly()},
|
||||
{QStringLiteral("current"), p == current}});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief LiveServer::openProject
|
||||
Open a saved project and make it current, as File > Open does but with
|
||||
no file dialog and no error windows: what is wrong comes back in the
|
||||
answer. A project already open is only made current.
|
||||
*/
|
||||
QJsonObject LiveServer::openProject(const QString &raw_path)
|
||||
{
|
||||
QETDiagramEditor *e = editor();
|
||||
if (!e) return failure(QStringLiteral("no QElectroTech editor window is open"));
|
||||
const QString path = raw_path.trimmed();
|
||||
if (path.isEmpty()) return failure(QStringLiteral("no path given"));
|
||||
const QFileInfo info(path);
|
||||
if (info.isRelative()) return failure(QStringLiteral("path must be absolute: %1").arg(path));
|
||||
if (!info.isFile()) return failure(QStringLiteral("%1 does not exist").arg(path));
|
||||
if (!info.isReadable()) return failure(QStringLiteral("%1 cannot be read (permissions)").arg(path));
|
||||
|
||||
QJsonObject extra{{QStringLiteral("opened"), true}};
|
||||
if (ProjectView *pv = e->viewForFile(info.absoluteFilePath())) {
|
||||
e->activateProject(pv);
|
||||
extra = {{QStringLiteral("opened"), false}, {QStringLiteral("already_open"), true}};
|
||||
} else if (QETApp::diagramEditorForFile(info.absoluteFilePath())) {
|
||||
return failure(QStringLiteral("%1 is open in another QElectroTech window; "
|
||||
"switch to it there").arg(path));
|
||||
} else {
|
||||
if (!e->openAndAddProject(info.absoluteFilePath(), false))
|
||||
return failure(QStringLiteral("QElectroTech could not open %1 (not a "
|
||||
"QElectroTech project, or a newer format)").arg(path));
|
||||
if (ProjectView *pv = e->viewForFile(info.absoluteFilePath())) e->activateProject(pv);
|
||||
}
|
||||
QJsonObject answer = status();
|
||||
for (auto it = extra.begin(); it != extra.end(); ++it) answer.insert(it.key(), it.value());
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief LiveServer::switchProject
|
||||
Make another open project current, by its index in "projects" (status)
|
||||
or by its file path.
|
||||
*/
|
||||
QJsonObject LiveServer::switchProject(const QJsonObject &request)
|
||||
{
|
||||
QETDiagramEditor *e = editor();
|
||||
if (!e) return failure(QStringLiteral("no QElectroTech editor window is open"));
|
||||
const QList<ProjectView *> views = e->openedProjects();
|
||||
ProjectView *target = nullptr;
|
||||
if (request.contains(QStringLiteral("index"))) {
|
||||
const int index = request.value(QStringLiteral("index")).toInt(-1);
|
||||
if (index < 0 || index >= views.count())
|
||||
return failure(QStringLiteral("no open project %1: %2 are open, counted from 0")
|
||||
.arg(index).arg(views.count()));
|
||||
target = views.at(index);
|
||||
} else {
|
||||
const QString path = request.value(QStringLiteral("path")).toString().trimmed();
|
||||
if (path.isEmpty()) return failure(QStringLiteral("give \"index\" (from \"projects\") or \"path\""));
|
||||
target = e->viewForFile(QFileInfo(path).absoluteFilePath());
|
||||
if (!target) return failure(QStringLiteral("%1 is not open; open_project opens it").arg(path));
|
||||
}
|
||||
e->activateProject(target);
|
||||
return status();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief LiveServer::undoLast
|
||||
Undo the newest step, only if the assistant made it: what the user did
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#ifndef LIVESERVER_H
|
||||
#define LIVESERVER_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
@@ -85,6 +86,9 @@ class LiveServer : public QObject
|
||||
QJsonObject command(const QString &id);
|
||||
QJsonObject showFolio(int folio);
|
||||
QJsonObject newProject(const QJsonObject &request);
|
||||
QJsonObject openProject(const QString &path);
|
||||
QJsonObject switchProject(const QJsonObject &request);
|
||||
QJsonArray openProjects() const;
|
||||
QJsonObject undoLast();
|
||||
QJsonObject screenshot();
|
||||
bool confirm(const QString &name, const QString &source);
|
||||
|
||||
Reference in New Issue
Block a user