Skip to content

Commit db8a572

Browse files
committed
feat: implement asset manager with reference updating and UI for file operations
1 parent 09a408e commit db8a572

6 files changed

Lines changed: 230 additions & 64 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
A powerful toolkit for Counter-Strike 2 Workshop Tools. Edit sound events, create smart props, build maps, manage keybindings, and customize loading screens.
2121

22-
[**Visit Official Website**](https://hammer5tools.github.io/)
22+
[**Visit Official Website**](https://hammer5tools.github.io/)
2323

2424
<p align="center">
2525
<video src="https://hammer5tools.github.io/videos/hero.mp4" controls="controls" width="100%" muted="true" loop="true" autoplay="true"></video>

src/editors/asset_manager/main.py

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from src.settings.main import get_addon_name, get_cs2_path, app_dir
88
from src.common import enable_dark_title_bar
99
from src.styles.common import apply_stylesheets
10+
from src.widgets.explorer.main import Explorer
1011

1112
class AssetManagerWidget(QWidget):
1213
def __init__(self, parent=None):
@@ -17,37 +18,33 @@ def __init__(self, parent=None):
1718
enable_dark_title_bar(self)
1819
apply_stylesheets(self)
1920

21+
# Explicitly set console dark theme since global stylesheet strips it out
22+
self.ui.log_output.setStyleSheet("QTextEdit { background-color: #1e1e1e; color: #ffffff; border: 1px solid #333333; }")
23+
2024
self.cs2_path = get_cs2_path()
2125
if not self.cs2_path:
2226
return
2327

2428
self.addon_name = get_addon_name()
2529
self.addon_content_path = os.path.join(self.cs2_path, 'content', 'csgo_addons', self.addon_name)
30+
31+
self.sources_to_move = []
2632

2733
self.setWindowFlags(Qt.Window)
2834
self.setWindowTitle("Move Assets")
2935
self.ui.source_tree.hide()
30-
self.sources_to_move = []
31-
32-
self.source_model = QFileSystemModel()
33-
self.source_model.setRootPath(self.addon_content_path)
34-
self.source_model.setNameFilters(['*.vmdl', '*.vsmart', '*.vmat', '*.vpcf', '*.vsndevts', '*.vsnd', '*.vtex'])
35-
self.source_model.setNameFilterDisables(False)
36-
37-
self.ui.source_tree.setModel(self.source_model)
38-
self.ui.source_tree.setRootIndex(self.source_model.index(self.addon_content_path))
39-
self.ui.source_tree.setColumnWidth(0, 250)
40-
self.ui.source_tree.setSelectionMode(self.ui.source_tree.SelectionMode.ExtendedSelection)
41-
self.ui.source_tree.setDragEnabled(True)
42-
43-
self.dest_model = QFileSystemModel()
44-
self.dest_model.setRootPath(self.addon_content_path)
45-
self.dest_model.setFilter(self.dest_model.filter() | QDir.Dirs | QDir.NoDotAndDotDot)
46-
47-
self.ui.dest_tree.setModel(self.dest_model)
48-
self.ui.dest_tree.setRootIndex(self.dest_model.index(self.addon_content_path))
49-
self.ui.dest_tree.setColumnWidth(0, 250)
50-
self.ui.dest_tree.setAcceptDrops(True)
36+
self.ui.source_tree.deleteLater()
37+
self.ui.dest_tree.hide()
38+
self.ui.dest_tree.deleteLater()
39+
40+
self.explorer = Explorer(
41+
tree_directory=self.addon_content_path,
42+
addon=self.addon_name,
43+
editor_name="AssetManager",
44+
use_internal_player=False,
45+
parent=self
46+
)
47+
self.ui.splitter.insertWidget(0, self.explorer.frame)
5148

5249
self.ui.btn_preview.clicked.connect(self.preview_move)
5350
self.ui.btn_apply.clicked.connect(self.apply_move)
@@ -65,11 +62,12 @@ def get_selected_sources(self):
6562
return self.sources_to_move
6663

6764
def get_selected_dest_dir(self):
68-
indexes = self.ui.dest_tree.selectionModel().selectedIndexes()
65+
indexes = self.explorer.tree.selectionModel().selectedIndexes()
6966
if indexes:
7067
for idx in indexes:
7168
if idx.column() == 0:
72-
path = self.dest_model.filePath(idx)
69+
src_idx = self.explorer.filter_proxy_model.mapToSource(idx)
70+
path = self.explorer.model.filePath(src_idx)
7371
if os.path.isdir(path):
7472
return path
7573
return os.path.dirname(path)

src/editors/asset_manager/move_worker.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ def run(self):
2020
try:
2121
os.makedirs(os.path.dirname(dst), exist_ok=True)
2222
shutil.move(src, dst)
23-
self.log.emit(f'Moved: {old_rel}{new_rel}')
24-
for m in updater.update_references(old_rel, new_rel):
25-
self.log.emit(f' Updated ref in: {m}')
23+
self.log.emit(f"Moved:\n {old_rel}\n -> {new_rel}")
24+
modified = updater.update_references(old_rel, new_rel)
25+
if modified:
26+
self.log.emit(f" Updated references in {len(modified)} files")
2627
except Exception as e:
2728
self.log.emit(f"Error moving {old_rel}: {e}")
2829
self.finished_move.emit()
Lines changed: 133 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,152 @@
11
import os
22

33
class ReferenceUpdater:
4-
SCANNABLE_EXTS = {'.vmdl', '.vsmart', '.vmat', '.vpcf', '.vsndevts', '.vsnd'}
4+
SCANNABLE_EXTS = {'.vmdl', '.vsmart', '.vmat', '.vpcf', '.vsndevts', '.vsnd', '.vmap'}
55

66
def __init__(self, addon_content_path: str):
77
self.addon_content_path = addon_content_path
88

9+
def _process_element_values(self, element, old_rel: str, new_rel: str) -> bool:
10+
"""Recursively process all string values in an Element, replacing old_rel with new_rel."""
11+
modified = False
12+
try:
13+
keys = list(element.Keys)
14+
except Exception:
15+
return False
16+
17+
for key in keys:
18+
try:
19+
val = element[key]
20+
except Exception:
21+
continue
22+
23+
if isinstance(val, str) and old_rel in val:
24+
try:
25+
element[key] = val.replace(old_rel, new_rel)
26+
modified = True
27+
except Exception:
28+
pass
29+
elif hasattr(val, 'Keys'):
30+
# Nested Element – recurse
31+
if self._process_element_values(val, old_rel, new_rel):
32+
modified = True
33+
elif hasattr(val, 'Count') and hasattr(val, 'Item'):
34+
# Array/List attribute
35+
try:
36+
for i in range(val.Count):
37+
item_val = val[i]
38+
if isinstance(item_val, str) and old_rel in item_val:
39+
val[i] = item_val.replace(old_rel, new_rel)
40+
modified = True
41+
elif hasattr(item_val, 'Keys'):
42+
if self._process_element_values(item_val, old_rel, new_rel):
43+
modified = True
44+
except Exception:
45+
pass
46+
47+
return modified
48+
49+
def _update_vmap_references(self, abs_path: str, old_rel: str, new_rel: str) -> bool:
50+
import tempfile
51+
import shutil
52+
53+
dmx_model = None
54+
temp_path = None
55+
try:
56+
from src.dotnet import setup_keyvalues2
57+
Datamodel, Element, DeferredMode = setup_keyvalues2()
58+
59+
# Load from a temporary copy so the original file is not locked
60+
with tempfile.NamedTemporaryFile(mode='wb', suffix='.vmap', delete=False) as tmp:
61+
temp_path = tmp.name
62+
with open(abs_path, 'rb') as src:
63+
shutil.copyfileobj(src, tmp)
64+
65+
dmx_model = Datamodel.Load(temp_path, DeferredMode.Automatic)
66+
if not dmx_model:
67+
return False
68+
69+
modified = False
70+
71+
# Process PrefixAttributes (AttributeList – a Dictionary<string, object>)
72+
if hasattr(dmx_model, 'PrefixAttributes') and dmx_model.PrefixAttributes:
73+
try:
74+
for key in list(dmx_model.PrefixAttributes.Keys):
75+
val = dmx_model.PrefixAttributes[key]
76+
if isinstance(val, str) and old_rel in val:
77+
dmx_model.PrefixAttributes[key] = val.replace(old_rel, new_rel)
78+
modified = True
79+
elif hasattr(val, 'Count') and hasattr(val, 'Item'):
80+
try:
81+
for i in range(val.Count):
82+
item_val = val[i]
83+
if isinstance(item_val, str) and old_rel in item_val:
84+
val[i] = item_val.replace(old_rel, new_rel)
85+
modified = True
86+
except Exception:
87+
pass
88+
except Exception as e:
89+
print(f"Warning: failed to process PrefixAttributes in {abs_path}: {e}")
90+
91+
# Process AllElements (ElementList – iterates as DictionaryEntry)
92+
# Each DictionaryEntry.Value is the actual Element object
93+
if hasattr(dmx_model, 'AllElements') and dmx_model.AllElements:
94+
try:
95+
for entry in dmx_model.AllElements:
96+
element = entry.Value if hasattr(entry, 'Value') else entry
97+
if self._process_element_values(element, old_rel, new_rel):
98+
modified = True
99+
except Exception as e:
100+
print(f"Warning: failed to process AllElements in {abs_path}: {e}")
101+
102+
if modified:
103+
# Save to the original path (not locked since we loaded from temp)
104+
dmx_model.Save(abs_path, dmx_model.Encoding, dmx_model.EncodingVersion)
105+
106+
if hasattr(dmx_model, 'Dispose'):
107+
dmx_model.Dispose()
108+
dmx_model = None
109+
import gc; gc.collect()
110+
111+
return modified
112+
except Exception as e:
113+
print(f"Error updating vmap references via .NET in {abs_path}: {e}")
114+
return False
115+
finally:
116+
if dmx_model is not None:
117+
try:
118+
if hasattr(dmx_model, 'Dispose'):
119+
dmx_model.Dispose()
120+
except Exception:
121+
pass
122+
if temp_path:
123+
try:
124+
os.unlink(temp_path)
125+
except Exception:
126+
pass
127+
9128
def update_references(self, old_rel: str, new_rel: str) -> list[str]:
10129
modified = []
11130
old_rel = old_rel.replace('\\', '/')
12131
new_rel = new_rel.replace('\\', '/')
13132
for root, _, files in os.walk(self.addon_content_path):
14133
for f in files:
15-
if os.path.splitext(f)[1].lower() not in self.SCANNABLE_EXTS:
134+
ext = os.path.splitext(f)[1].lower()
135+
if ext not in self.SCANNABLE_EXTS:
16136
continue
17137
abs_path = os.path.join(root, f)
18138
try:
19-
with open(abs_path, 'r', encoding='utf-8', errors='ignore') as file:
20-
text = file.read()
21-
if old_rel in text:
22-
new_text = text.replace(old_rel, new_rel)
23-
with open(abs_path, 'w', encoding='utf-8') as file:
24-
file.write(new_text)
25-
modified.append(abs_path)
139+
if ext == '.vmap':
140+
if self._update_vmap_references(abs_path, old_rel, new_rel):
141+
modified.append(abs_path)
142+
else:
143+
with open(abs_path, 'r', encoding='utf-8', errors='ignore') as file:
144+
text = file.read()
145+
if old_rel in text:
146+
new_text = text.replace(old_rel, new_rel)
147+
with open(abs_path, 'w', encoding='utf-8') as file:
148+
file.write(new_text)
149+
modified.append(abs_path)
26150
except Exception as e:
27151
print(f"Error updating references in {abs_path}: {e}")
28152
return modified

src/editors/asset_manager/ui_main.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
QImage, QKeySequence, QLinearGradient, QPainter,
1717
QPalette, QPixmap, QRadialGradient, QTransform)
1818
from PySide6.QtWidgets import (QApplication, QHeaderView, QPushButton, QSizePolicy,
19-
QSplitter, QTextEdit, QTreeView, QVBoxLayout,
20-
QWidget)
19+
QSplitter, QTextEdit, QTreeView, QVBoxLayout, QHBoxLayout,
20+
QWidget, QLabel)
2121

2222
class Ui_AssetManagerWidget(object):
2323
def setupUi(self, AssetManagerWidget):
@@ -36,27 +36,42 @@ def setupUi(self, AssetManagerWidget):
3636
self.dest_tree.setObjectName(u"dest_tree")
3737
self.splitter.addWidget(self.dest_tree)
3838

39+
self.log_output = QTextEdit(self.splitter)
40+
self.log_output.setObjectName(u"log_output")
41+
self.splitter.addWidget(self.log_output)
42+
3943
self.verticalLayout.addWidget(self.splitter)
4044

45+
self.horizontalLayout = QHBoxLayout()
46+
self.horizontalLayout.setObjectName(u"horizontalLayout")
47+
4148
self.btn_preview = QPushButton(AssetManagerWidget)
4249
self.btn_preview.setObjectName(u"btn_preview")
4350

44-
self.verticalLayout.addWidget(self.btn_preview)
51+
self.horizontalLayout.addWidget(self.btn_preview)
4552

4653
self.btn_apply = QPushButton(AssetManagerWidget)
4754
self.btn_apply.setObjectName(u"btn_apply")
4855

49-
self.verticalLayout.addWidget(self.btn_apply)
56+
self.horizontalLayout.addWidget(self.btn_apply)
5057

5158
self.btn_undo = QPushButton(AssetManagerWidget)
5259
self.btn_undo.setObjectName(u"btn_undo")
5360

54-
self.verticalLayout.addWidget(self.btn_undo)
61+
self.horizontalLayout.addWidget(self.btn_undo)
5562

56-
self.log_output = QTextEdit(AssetManagerWidget)
57-
self.log_output.setObjectName(u"log_output")
63+
self.verticalLayout.addLayout(self.horizontalLayout)
5864

59-
self.verticalLayout.addWidget(self.log_output)
65+
self.warning_label = QLabel(AssetManagerWidget)
66+
self.warning_label.setObjectName(u"warning_label")
67+
sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
68+
sizePolicy.setHorizontalStretch(0)
69+
sizePolicy.setVerticalStretch(0)
70+
self.warning_label.setSizePolicy(sizePolicy)
71+
self.warning_label.setStyleSheet("color: #ffaa00; font-style: italic; font-size: 11px; margin-top: 5px;")
72+
self.warning_label.setAlignment(Qt.AlignCenter)
73+
self.warning_label.setWordWrap(True)
74+
self.verticalLayout.addWidget(self.warning_label)
6075

6176

6277
self.retranslateUi(AssetManagerWidget)
@@ -69,5 +84,6 @@ def retranslateUi(self, AssetManagerWidget):
6984
self.btn_preview.setText(QCoreApplication.translate("AssetManagerWidget", u"Preview Move", None))
7085
self.btn_apply.setText(QCoreApplication.translate("AssetManagerWidget", u"Apply", None))
7186
self.btn_undo.setText(QCoreApplication.translate("AssetManagerWidget", u"Undo Last Move", None))
87+
self.warning_label.setText(QCoreApplication.translate("AssetManagerWidget", u"Note: This tool might not work perfectly. Backup your project or use git (recommended) before proceeding.", None))
7288
# retranslateUi
7389

0 commit comments

Comments
 (0)