feat(workspace): 工作空间弹窗适配机构工作空间新结构——树根改项目目录并挂 apps/modules 引用节点,路径解析统一 resolve_workspace_path

This commit is contained in:
ymq 2026-08-24 17:56:48 +08:00
parent 524abb23e6
commit 9ec2ad473d
11 changed files with 94 additions and 53 deletions

View File

@ -13,15 +13,16 @@ session_id = (params_kw or {}).get('session_id', '') or ''
dbname = get_module_dbname('pipeline-sdlc') dbname = get_module_dbname('pipeline-sdlc')
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
ws_dir, _ = await get_workspace_dir(sor, uid, session_id) project_dir, _ = await get_project_dir(sor, uid, session_id)
space_dir, _ = await get_space_dir(sor, uid, session_id)
if not file_id or not ws_dir: if not file_id or not space_dir:
return {"error": "未指定文件或无工作空间", "markdown": ""} return {"error": "未指定文件或无工作空间", "markdown": ""}
full_path = ws_dir + '/' + file_id full_path = resolve_workspace_path(project_dir, space_dir, file_id)
# 路径穿越校验 # 路径穿越校验
real_ws = os.path.realpath(ws_dir) real_ws = os.path.realpath(space_dir)
real_full = os.path.realpath(full_path) real_full = os.path.realpath(full_path)
if not real_full.startswith(real_ws + os.sep): if not real_full.startswith(real_ws + os.sep):
return {"error": "非法路径", "markdown": ""} return {"error": "非法路径", "markdown": ""}

View File

@ -15,9 +15,10 @@ session_id = (params_kw or {}).get('session_id', '') or ''
dbname = get_module_dbname('pipeline-sdlc') dbname = get_module_dbname('pipeline-sdlc')
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
ws_dir, _ = await get_workspace_dir(sor, uid, session_id) project_dir, _ = await get_project_dir(sor, uid, session_id)
space_dir, _ = await get_space_dir(sor, uid, session_id)
if not file_id or not ws_dir: if not file_id or not space_dir:
return {"success": False, "error": "未指定文件或无工作空间"} return {"success": False, "error": "未指定文件或无工作空间"}
if not docdata_str: if not docdata_str:
@ -28,10 +29,10 @@ try:
except Exception as e: except Exception as e:
return {"success": False, "error": f"文档数据解析失败: {e}"} return {"success": False, "error": f"文档数据解析失败: {e}"}
full_path = ws_dir + '/' + file_id full_path = resolve_workspace_path(project_dir, space_dir, file_id)
# 路径穿越校验 # 路径穿越校验
real_ws = os.path.realpath(ws_dir) real_ws = os.path.realpath(space_dir)
real_full = os.path.realpath(full_path) real_full = os.path.realpath(full_path)
if not real_full.startswith(real_ws + os.sep): if not real_full.startswith(real_ws + os.sep):
return {"success": False, "error": "非法路径"} return {"success": False, "error": "非法路径"}

View File

@ -13,15 +13,16 @@ session_id = (params_kw or {}).get('session_id', '') or ''
dbname = get_module_dbname('pipeline-sdlc') dbname = get_module_dbname('pipeline-sdlc')
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
ws_dir, _ = await get_workspace_dir(sor, uid, session_id) project_dir, _ = await get_project_dir(sor, uid, session_id)
space_dir, _ = await get_space_dir(sor, uid, session_id)
if not file_id or file_id == '__root__': if not file_id or file_id == '__root__':
return {"status": "error", "message": "不能删除根目录"} return {"status": "error", "message": "不能删除根目录"}
full_path = ws_dir + '/' + file_id if ws_dir else file_id full_path = resolve_workspace_path(project_dir, space_dir, file_id)
# 防止路径穿越:确保最终路径在 ws_dir 内 # 防止路径穿越:确保最终路径在机构工作空间(space_dir)内
real_ws = os.path.realpath(ws_dir) real_ws = os.path.realpath(space_dir)
real_full = os.path.realpath(full_path) real_full = os.path.realpath(full_path)
if not real_full.startswith(real_ws + os.sep): if not real_full.startswith(real_ws + os.sep):
return {"status": "error", "message": "非法路径"} return {"status": "error", "message": "非法路径"}

View File

@ -16,15 +16,16 @@ session_id = (params_kw or {}).get('session_id', '') or ''
dbname = get_module_dbname('pipeline-sdlc') dbname = get_module_dbname('pipeline-sdlc')
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
ws_dir, _ = await get_workspace_dir(sor, uid, session_id) project_dir, _ = await get_project_dir(sor, uid, session_id)
space_dir, _ = await get_space_dir(sor, uid, session_id)
if not file_id or file_id == '__root__': if not file_id or file_id == '__root__':
return {"widgettype": "Message", "options": {"title": "错误", "message": "未指定文件"}} return {"widgettype": "Message", "options": {"title": "错误", "message": "未指定文件"}}
full_path = ws_dir + '/' + file_id if ws_dir else file_id full_path = resolve_workspace_path(project_dir, space_dir, file_id)
# 路径穿越校验 # 路径穿越校验
real_ws = os.path.realpath(ws_dir) real_ws = os.path.realpath(space_dir)
real_full = os.path.realpath(full_path) real_full = os.path.realpath(full_path)
if not real_full.startswith(real_ws + os.sep): if not real_full.startswith(real_ws + os.sep):
return {"widgettype": "Message", "options": {"title": "错误", "message": "非法路径"}} return {"widgettype": "Message", "options": {"title": "错误", "message": "非法路径"}}

View File

@ -14,12 +14,10 @@ session_id = (params_kw or {}).get('session_id', '') or ''
dbname = get_module_dbname('pipeline-sdlc') dbname = get_module_dbname('pipeline-sdlc')
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
ws_dir, _ = await get_workspace_dir(sor, uid, session_id) project_dir, _ = await get_project_dir(sor, uid, session_id)
space_dir, _ = await get_space_dir(sor, uid, session_id)
if not folder_id or folder_id == '__root__': full_dir = resolve_workspace_path(project_dir, space_dir, folder_id)
full_dir = ws_dir
else:
full_dir = ws_dir + '/' + folder_id if ws_dir else folder_id
if not os.path.isdir(full_dir): if not os.path.isdir(full_dir):
return {"widgettype": "Text", "options": {"text": "目录不可用: " + folder_id, "cfontsize": 0.9}} return {"widgettype": "Text", "options": {"text": "目录不可用: " + folder_id, "cfontsize": 0.9}}

View File

@ -15,12 +15,13 @@ session_id = (params_kw or {}).get('session_id', '') or ''
dbname = get_module_dbname('pipeline-sdlc') dbname = get_module_dbname('pipeline-sdlc')
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
ws_dir, _ = await get_workspace_dir(sor, uid, session_id) project_dir, _ = await get_project_dir(sor, uid, session_id)
space_dir, _ = await get_space_dir(sor, uid, session_id)
if not file_id: if not file_id:
return {"widgettype": "Message", "options": {"title": "打开失败", "message": "未指定文件"}} return {"widgettype": "Message", "options": {"title": "打开失败", "message": "未指定文件"}}
full_path = ws_dir + '/' + file_id if ws_dir else file_id full_path = resolve_workspace_path(project_dir, space_dir, file_id)
if not os.path.isfile(full_path): if not os.path.isfile(full_path):
return {"widgettype": "Message", "options": {"title": "打开失败", "message": "文件不存在: " + file_id}} return {"widgettype": "Message", "options": {"title": "打开失败", "message": "文件不存在: " + file_id}}

View File

@ -13,11 +13,11 @@ session_id = (params_kw or {}).get('session_id', '') or ''
_sid_q = ("?session_id=" + session_id) if session_id else "" _sid_q = ("?session_id=" + session_id) if session_id else ""
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
ws_dir, _ = await get_workspace_dir(sor, uid, session_id) project_dir, _ = await get_project_dir(sor, uid, session_id)
pname = os.path.basename(ws_dir) if ws_dir else '' pname = os.path.basename(project_dir) if project_dir else ''
if not ws_dir: if not project_dir:
resp = { resp = {
"widgettype": "PopupWindow", "widgettype": "PopupWindow",
"options": {"title": "提示", "cwidth": 30, "cheight": 8, "auto_open": True}, "options": {"title": "提示", "cwidth": 30, "cheight": 8, "auto_open": True},

View File

@ -1,4 +1,4 @@
# workspace_tree.dspy - 初始只返回根节点(无children),靠点击展开 # workspace_tree.dspy - 项目目录树:根=项目目录,额外挂项目用到的 apps/modules 子目录
import os import os
@ -13,11 +13,12 @@ session_id = (params_kw or {}).get('session_id', '') or ''
dbname = get_module_dbname('pipeline-sdlc') dbname = get_module_dbname('pipeline-sdlc')
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
ws_dir, _ = await get_workspace_dir(sor, uid, session_id) project_dir, _ = await get_project_dir(sor, uid, session_id)
space_dir, _ = await get_space_dir(sor, uid, session_id)
if not ws_dir or not os.path.isdir(ws_dir): if not project_dir or not os.path.isdir(project_dir):
if not node_id: if not node_id:
return [{"id": "__root__", "parentid": "", "label": "📁 工作空间(不可用)", "path": ws_dir, "is_leaf": False}] return [{"id": "__root__", "parentid": "", "label": "📁 项目目录(不可用)", "path": project_dir, "is_leaf": False}]
return [] return []
# 初始加载:只返回根节点 # 初始加载:只返回根节点
@ -25,35 +26,69 @@ if not node_id:
return [{ return [{
"id": "__root__", "id": "__root__",
"parentid": "", "parentid": "",
"label": "📁 " + os.path.basename(ws_dir), "label": "📁 " + os.path.basename(project_dir),
"path": ws_dir, "path": project_dir,
"is_leaf": False "is_leaf": False
}] }]
# 展开节点:__root__ 特殊处理,其余用相对路径 # 展开节点:__root__ 特殊处理(加 apps/modules 引用),@apps/@modules 扫描机构工作空间,其余扫描项目目录
if node_id == '__root__': if node_id == '__root__':
scan_path = ws_dir scan_path = project_dir
id_prefix = '' id_prefix = ''
else: extra_children = True
scan_path = ws_dir + '/' + node_id elif node_id.startswith('@apps/'):
scan_path = os.path.join(space_dir, 'apps', node_id[len('@apps/'):])
id_prefix = node_id + '/' id_prefix = node_id + '/'
extra_children = False
elif node_id.startswith('@modules/'):
scan_path = os.path.join(space_dir, 'modules', node_id[len('@modules/'):])
id_prefix = node_id + '/'
extra_children = False
else:
scan_path = resolve_workspace_path(project_dir, space_dir, node_id)
id_prefix = node_id + '/'
extra_children = False
if not os.path.isdir(scan_path): if not os.path.isdir(scan_path):
return [] return []
items = [] items = []
# 根节点:额外挂项目用到的 apps 子目录 + modules 子目录
if extra_children:
apps, modules = await get_project_apps_modules(sor, uid, session_id)
for app in apps:
app_path = os.path.join(space_dir, 'apps', app)
if os.path.isdir(app_path):
items.append({
"id": "@apps/" + app,
"parentid": node_id,
"label": "📱 " + app,
"path": app_path,
"is_leaf": False
})
for m in modules:
m_path = os.path.join(space_dir, 'modules', m)
if os.path.isdir(m_path):
items.append({
"id": "@modules/" + m,
"parentid": node_id,
"label": "📦 " + m,
"path": m_path,
"is_leaf": False
})
# 扫描常规内容(子目录)
try: try:
entries = sorted(os.listdir(scan_path)) entries = sorted(os.listdir(scan_path))
except Exception: except Exception:
return [] entries = []
for name in entries: for name in entries:
full = os.path.join(scan_path, name) full = os.path.join(scan_path, name)
if name.startswith('.') or name == '__pycache__': if name.startswith('.') or name == '__pycache__':
continue continue
if os.path.isdir(full): if os.path.isdir(full):
# 叶子判定:目录下是否还有「可见」子目录(排除隐藏目录和 __pycache__,与下方扫描一致)。
# 无可见子目录 → 叶子(不可展开)
has_subdir = False has_subdir = False
try: try:
for sub in os.listdir(full): for sub in os.listdir(full):

View File

@ -16,15 +16,13 @@ session_id = (params_kw or {}).get('session_id', '') or ''
dbname = get_module_dbname('pipeline-sdlc') dbname = get_module_dbname('pipeline-sdlc')
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
ws_dir, _ = await get_workspace_dir(sor, uid, session_id) project_dir, _ = await get_project_dir(sor, uid, session_id)
space_dir, _ = await get_space_dir(sor, uid, session_id)
if not ws_dir or not os.path.isdir(ws_dir): if not project_dir or not os.path.isdir(project_dir):
return {"status": "error", "message": "工作空间目录不可用"} return {"status": "error", "message": "项目目录不可用"}
if not folder_id or folder_id == '__root__': target_dir = resolve_workspace_path(project_dir, space_dir, folder_id)
target_dir = ws_dir
else:
target_dir = ws_dir + '/' + folder_id
if not os.path.isdir(target_dir): if not os.path.isdir(target_dir):
return {"status": "error", "message": "目标目录不存在: " + folder_id} return {"status": "error", "message": "目标目录不存在: " + folder_id}

View File

@ -15,15 +15,16 @@ session_id = (params_kw or {}).get('session_id', '') or ''
dbname = get_module_dbname('pipeline-sdlc') dbname = get_module_dbname('pipeline-sdlc')
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
ws_dir, _ = await get_workspace_dir(sor, uid, session_id) project_dir, _ = await get_project_dir(sor, uid, session_id)
space_dir, _ = await get_space_dir(sor, uid, session_id)
if not file_id or file_id == '__root__': if not file_id or file_id == '__root__':
return {"widgettype": "Message", "options": {"title": "查看失败", "message": "未指定文件"}} return {"widgettype": "Message", "options": {"title": "查看失败", "message": "未指定文件"}}
full_path = ws_dir + '/' + file_id if ws_dir else file_id full_path = resolve_workspace_path(project_dir, space_dir, file_id)
# 路径穿越校验 # 路径穿越校验
real_ws = os.path.realpath(ws_dir) real_ws = os.path.realpath(space_dir)
real_full = os.path.realpath(full_path) real_full = os.path.realpath(full_path)
if not real_full.startswith(real_ws + os.sep): if not real_full.startswith(real_ws + os.sep):
return {"widgettype": "Message", "options": {"title": "错误", "message": "非法路径"}} return {"widgettype": "Message", "options": {"title": "错误", "message": "非法路径"}}

View File

@ -13,19 +13,20 @@ session_id = (params_kw or {}).get('session_id', '') or ''
dbname = get_module_dbname('pipeline-sdlc') dbname = get_module_dbname('pipeline-sdlc')
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
ws_dir, _ = await get_workspace_dir(sor, uid, session_id) project_dir, _ = await get_project_dir(sor, uid, session_id)
space_dir, _ = await get_space_dir(sor, uid, session_id)
if not file_id or not ws_dir: if not file_id or not space_dir:
r = DictObject() r = DictObject()
r.host = 'localhost' r.host = 'localhost'
r.username = 'pipeline' r.username = 'pipeline'
r.cmdargs = ['echo 未指定文件'] r.cmdargs = ['echo 未指定文件']
return r return r
full_path = ws_dir + '/' + file_id full_path = resolve_workspace_path(project_dir, space_dir, file_id)
# 路径穿越校验 # 路径穿越校验
real_ws = os.path.realpath(ws_dir) real_ws = os.path.realpath(space_dir)
real_full = os.path.realpath(full_path) real_full = os.path.realpath(full_path)
if not real_full.startswith(real_ws + os.sep): if not real_full.startswith(real_ws + os.sep):
r = DictObject() r = DictObject()
@ -34,6 +35,9 @@ if not real_full.startswith(real_ws + os.sep):
r.cmdargs = ['echo 非法路径'] r.cmdargs = ['echo 非法路径']
return r return r
# 相对 space_dir 的路径(bwrap 挂载 space_dir 到 /home,vi 用相对路径)
rel_path = os.path.relpath(full_path, space_dir)
# bwrap 沙箱路径(不存在则拒绝终端,安全优先,不给完整 shell) # bwrap 沙箱路径(不存在则拒绝终端,安全优先,不给完整 shell)
bwrap = shutil.which('bwrap') bwrap = shutil.which('bwrap')
if not bwrap and os.path.exists('/d/pipeline/pipeline-app/bin/bwrap'): if not bwrap and os.path.exists('/d/pipeline/pipeline-app/bin/bwrap'):
@ -65,7 +69,7 @@ cmd = (
' --bind ' + shlex.quote(real_ws) + ' /home' ' --bind ' + shlex.quote(real_ws) + ' /home'
' --chdir /home' ' --chdir /home'
' --setenv HOME /home' ' --setenv HOME /home'
' -- vi ' + shlex.quote(file_id) ' -- vi ' + shlex.quote(rel_path)
) )
r.cmdargs = [cmd] r.cmdargs = [cmd]
return r return r