3 # Copyright (c) 2005 Fredrik Kuivinen <freku045@student.liu.se>
4 # Copyright (c) 2005 Mark Williamson <mark.williamson@cl.cam.ac.uk>
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License version 2 as
8 # published by the Free Software Foundation.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
20 import sys
, math
, random
, qt
, os
, re
, signal
, sets
21 from optparse
import OptionParser
23 # Determine semantics according to executable name. Default to git.
24 if os
.path
.basename(sys
.argv
[0]) == 'hgct':
29 qconnect
= qt
.QObject
.connect
33 class CommitError(Exception):
34 def __init__(self
, operation
, msg
):
35 self
.operation
= operation
41 class MyListItem(qt
.QCheckListItem
):
42 def __init__(self
, parent
, file, commitMsg
= False):
43 qt
.QCheckListItem
.__init
__(self
, parent
, file.text
, qt
.QCheckListItem
.CheckBox
)
45 self
.commitMsg
= commitMsg
47 def compare(self
, item
, col
, asc
):
59 return cmp(self
.file.srcName
, item
.file.srcName
)
61 def paintCell(self
, p
, cg
, col
, w
, a
):
63 qt
.QListViewItem
.paintCell(self
, p
, cg
, col
, w
, a
)
65 qt
.QCheckListItem
.paintCell(self
, p
, cg
, col
, w
, a
)
68 return self
.state() == qt
.QCheckListItem
.On
70 def setSelected(self
, s
):
72 self
.setState(qt
.QCheckListItem
.On
)
74 self
.setState(qt
.QCheckListItem
.Off
)
76 class MyListView(qt
.QListView
):
77 def __init__(self
, parent
=None, name
=None):
78 qt
.QListView
.__init
__(self
, parent
, name
)
81 return ListViewIterator(self
)
83 class ListViewIterator
:
84 def __init__(self
, listview
):
85 self
.it
= qt
.QListViewItemIterator(listview
)
88 cur
= self
.it
.current()
101 class MainWidget(qt
.QMainWindow
):
102 def __init__(self
, options
, parent
=None, name
=None):
103 qt
.QMainWindow
.__init
__(self
, parent
, name
)
104 self
.setCaption(applicationName
)
106 splitter
= qt
.QSplitter(Qt
.Vertical
, self
)
107 self
.setCentralWidget(splitter
)
108 self
.splitter
= splitter
110 # The file list and file filter widgets are part of this layout widget.
111 self
.filesLayout
= qt
.QVBox(splitter
)
114 fW
= MyListView(self
.filesLayout
)
117 fW
.setSelectionMode(qt
.QListView
.NoSelection
)
118 fW
.addColumn('Description')
119 fW
.setResizeMode(qt
.QListView
.AllColumns
)
122 self
.filterLayout
= qt
.QHBox(self
.filesLayout
)
123 self
.filterClear
= qt
.QPushButton("&Clear", self
.filterLayout
)
124 self
.filterLabel
= qt
.QLabel(" File filter: ", self
.filterLayout
)
125 qconnect(self
.filterClear
, qt
.SIGNAL("clicked()"), self
.clearFilter
)
126 self
.filter = qt
.QLineEdit(self
.filterLayout
)
127 self
.filterLabel
.setBuddy(self
.filter)
129 qconnect(self
.filter, qt
.SIGNAL("textChanged(const QString&)"), self
.updateFilter
)
131 self
.newCurLambda
= lambda i
: self
.currentChange(i
)
132 qconnect(fW
, qt
.SIGNAL("currentChanged(QListViewItem*)"), self
.newCurLambda
)
134 # The diff viewing widget
135 self
.text
= qt
.QWidgetStack(splitter
)
137 ops
= qt
.QPopupMenu(self
)
138 ops
.insertItem("Commit Selected Files", self
.commit
, Qt
.CTRL
+Qt
.Key_T
)
139 ops
.insertItem("Refresh", self
.refreshFiles
, Qt
.CTRL
+Qt
.Key_R
)
140 ops
.insertItem("Select All", self
.selectAll
, Qt
.CTRL
+Qt
.Key_A
)
141 ops
.insertItem("Unselect All", self
.unselectAll
, Qt
.CTRL
+Qt
.Key_U
)
142 ops
.insertItem("Preferences...", self
.showPrefs
, Qt
.CTRL
+Qt
.Key_P
)
145 m
.insertItem("&Operations", ops
)
147 h
= qt
.QPopupMenu(self
)
148 h
.insertItem("&About", self
.about
)
149 m
.insertItem("&Help", h
)
151 qconnect(fW
, qt
.SIGNAL("contextMenuRequested(QListViewItem*, const QPoint&, int)"),
152 self
.contextMenuRequestedSlot
)
153 self
.fileOps
= qt
.QPopupMenu(self
)
154 self
.fileOps
.insertItem("Toggle selection", self
.toggleFile
)
155 self
.fileOps
.insertItem("Edit", self
.editFile
, Qt
.CTRL
+Qt
.Key_E
)
156 self
.fileOps
.insertItem("Discard changes", self
.discardFile
)
157 self
.fileOps
.insertItem("Ignore file", self
.ignoreFile
)
159 # The following attribute is set by contextMenuRequestedSlot
160 # and currentChange and used by the fileOps
161 self
.currentContextItem
= None
163 self
.patchColors
= {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
168 f
.text
= "Commit message"
169 f
.textW
= self
.newTextEdit()
170 f
.textW
.setTextFormat(Qt
.PlainText
)
171 f
.textW
.setReadOnly(False)
172 f
.textW
.setText(settings().signoff
)
175 self
.createCmitItem()
176 self
.editorProcesses
= sets
.Set()
179 self
.options
= options
181 def loadSettings(self
):
182 self
.splitter
.setSizes(settings().splitter
)
184 def closeEvent(self
, e
):
186 settings().width
= s
.width()
187 settings().height
= s
.height()
188 settings().splitter
= self
.splitter
.sizes()
191 def createCmitItem(self
):
192 self
.cmitItem
= MyListItem(self
.filesW
, self
.cmitFile
, True)
193 self
.cmitItem
.setSelectable(False)
194 self
.filesW
.insertItem(self
.cmitItem
)
195 self
.cmitFile
.listViewItem
= self
.cmitItem
197 def about(self
, ignore
):
198 qt
.QMessageBox
.about(self
, "About " + applicationName
,
199 "<qt><center><h1>" + applicationName
+ " " + version
+ """</h1></center>\n
200 <center>Copyright © 2005 Fredrik Kuivinen <freku045@student.liu.se>
202 <center>Copyright © 2005 Mark Williamson <maw48@cl.cam.ac.uk>
203 </center>\n<p> This program is free software; you can redistribute it and/or
204 modify it under the terms of the GNU General Public License version 2 as
205 published by the Free Software Foundation.</p></qt>""")
207 def contextMenuRequestedSlot(self
, item
, pos
, col
):
208 if item
and not item
.commitMsg
:
209 self
.currentContextItem
= item
210 self
.fileOps
.exec_loop(qt
.QCursor
.pos())
212 self
.currentContextItem
= None
214 def toggleFile(self
, ignored
):
215 it
= self
.currentContextItem
220 it
.setSelected(False)
224 def editFile(self
, ignored
):
225 it
= self
.currentContextItem
231 qt
.QMessageBox
.warning(self
, 'No editor found',
232 '''No editor found. Gct looks for an editor to execute in the environment
233 variable GCT_EDITOR, if that variable is not set it will use the variable
237 # This piece of code is not entirely satisfactory. If the user
238 # has EDITOR set to 'vi', or some other non-X application, the
239 # editor will be started in the terminal which (h)gct was
240 # started in. A better approach would be to close stdin and
241 # stdout after the fork but before the exec, but this doesn't
242 # seem to be possible with QProcess.
244 p
.addArgument(it
.file.dstName
)
245 p
.setCommunication(0)
246 qconnect(p
, qt
.SIGNAL('processExited()'), self
.editorExited
)
247 if not p
.launch(qt
.QByteArray()):
248 qt
.QMessageBox
.warning(self
, 'Failed to launch editor',
249 shortName
+ ' failed to launch the ' + \
250 'editor. The command used was: ' + \
251 ed
+ ' ' + it
.file.dstName
)
253 self
.editorProcesses
.add(p
)
255 def editorExited(self
):
257 status
= p
.exitStatus()
258 file = str(p
.arguments()[1])
259 editor
= str(p
.arguments()[0]) + ' ' + file
260 if not p
.normalExit():
261 qt
.QMessageBox
.warning(self
, 'Editor failure',
262 'The editor, ' + editor
+ ', exited abnormally.')
264 qt
.QMessageBox
.warning(self
, 'Editor failure',
265 'The editor, ' + editor
+ ', exited with exit code ' + str(status
))
267 self
.editorProcesses
.remove(p
)
268 scm
.doUpdateCache(file)
271 def discardFile(self
, ignored
):
272 it
= self
.currentContextItem
276 scm
.discardFile(it
.file)
279 def ignoreFile(self
, ignored
):
280 it
= self
.currentContextItem
284 scm
.ignoreFile(it
.file)
287 def currentChange(self
, item
):
288 self
.text
.raiseWidget(item
.file.textW
)
290 self
.currentContextItem
= item
292 def commit(self
, id):
297 for item
in self
.filesW
:
298 debug("file: " + item
.file.text
)
299 if item
.isSelected():
300 selFileNames
.append(item
.file.text
)
301 commitFiles
.append(item
.file)
303 keepFiles
.append(item
.file)
305 commitMsg
= str(self
.cmitItem
.file.textW
.text())
308 qt
.QMessageBox
.information(self
, "Commit - " + applicationName
,
309 "No files selected for commit.", "&Ok")
312 commitMsg
= fixCommitMsgWhiteSpace(commitMsg
)
313 if scm
.commitIsMerge():
314 mergeMsg
= scm
.mergeMessage()
318 if(qt
.QMessageBox
.question(self
, "Confirm Commit - " + applicationName
,
319 '<qt><p>' + mergeMsg
+ '</p><p>Do you want to commit the following file(s):</p><blockquote>' +
320 '<br>'.join(selFileNames
) +
321 '''</blockquote><p>with the commit message:</p><blockquote><pre>''' +
322 str(qt
.QStyleSheet
.escape(commitMsg
)) + '</pre></blockquote></qt>',
327 scm
.doCommit(keepFiles
, commitFiles
, commitMsg
)
328 except CommitError
, e
:
329 qt
.QMessageBox
.warning(self
, "Commit Failed - " + applicationName
,
330 "Commit failed during " + e
.operation
+ ": " + e
.msg
,
333 qt
.QMessageBox
.warning(self
, "Commit Failed - " + applicationName
,
334 "Commit failed: " + e
.strerror
,
337 if not self
.options
.oneshot
:
338 self
.cmitItem
.file.textW
.setText(settings().signoff
)
341 if self
.options
.oneshot
:
345 def getFileState(self
):
347 cur
= self
.filesW
.currentItem()
348 if cur
and cur
!= self
.cmitItem
:
349 ret
.current
= self
.filesW
.currentItem().file.dstName
352 ret
.selected
= sets
.Set()
354 for x
in self
.filesW
:
356 ret
.selected
.add(x
.file.dstName
)
360 def restoreFileState(self
, state
):
362 f
.listViewItem
.setSelected(f
.dstName
in state
.selected
)
364 for x
in self
.filesW
:
365 if x
.file.dstName
== state
.current
:
366 self
.filesW
.setCurrentItem(x
)
368 def newTextEdit(self
):
370 self
.text
.addWidget(ret
)
373 def setFiles(self
, files
):
374 state
= self
.getFileState()
376 self
.createCmitItem()
378 self
.text
.removeWidget(f
.textW
)
379 f
.listViewItem
= None
383 f
.textW
= self
.newTextEdit()
384 f
.textW
.setReadOnly(True)
385 f
.textW
.setTextFormat(Qt
.RichText
)
386 f
.textW
.setText(formatPatchRichText(f
.patch
, self
.patchColors
))
389 f
.listViewItem
= MyListItem(self
.filesW
, f
)
390 # Only display files that match the filter.
391 f
.listViewItem
.setVisible(self
.filterMatch(f
))
392 self
.filesW
.insertItem(f
.listViewItem
)
394 self
.filesW
.setCurrentItem(self
.cmitItem
)
396 # For some reason the currentChanged signal isn't emitted
397 # here. We call currentChange ourselves instead.
398 self
.currentChange(self
.cmitItem
)
400 self
.restoreFileState(state
)
402 def refreshFiles(self
, ignored
=None):
403 files
= scm
.getFiles()
404 if settings().quitOnNoChanges
and len(files
) == 0:
409 return len(files
) > 0
411 def filterMatch(self
, file):
412 return file.dstName
.find(str(self
.filter.text())) != -1
414 def updateFilter(self
, ignored
=None):
415 for w
in self
.filesW
:
416 w
.setVisible(self
.filterMatch(w
.file))
418 def clearFilter(self
):
419 self
.filter.setText("")
422 for x
in self
.filesW
:
426 def unselectAll(self
):
427 for x
in self
.filesW
:
432 if settings().showSettings():
435 commitMsgRE
= re
.compile('[ \t\r\f\v]*\n\\s*\n')
436 def fixCommitMsgWhiteSpace(msg
):
439 msg
= re
.sub(commitMsgRE
, '\n\n', msg
)
443 def formatPatchRichText(patch
, colors
):
444 ret
= ['<qt><pre><font color="', colors
['std'], '">']
446 for l
in patch
.split('\n'):
453 if c
== '+': style
= 'new'
454 elif c
== '-': style
= 'remove'
455 elif c
== '@': style
= 'head'
457 ret
.extend(['</font><font color="', colors
[style
], '">'])
459 line
= qt
.QStyleSheet
.escape(l
).ascii()
464 ret
.extend([line
, '\n'])
465 ret
.append('</pre></qt>')
469 if os
.environ
.has_key('GCT_EDITOR'):
470 return os
.environ
['GCT_EDITOR']
471 elif os
.environ
.has_key('EDITOR'):
472 return os
.environ
['EDITOR']
478 app
= qt
.QApplication(sys
.argv
)
480 optParser
= OptionParser(usage
="%prog [--gui] [--one-shot]", version
=applicationName
+ ' ' + version
)
481 optParser
.add_option('-g', '--gui', action
='store_true', dest
='gui',
482 help='Unconditionally start the GUI')
483 optParser
.add_option('-o', '--one-shot', action
='store_true', dest
='oneshot',
484 help="Do (at most) one commit, then exit.")
485 (options
, args
) = optParser
.parse_args(app
.argv()[1:])
487 mw
= MainWidget(options
)
489 if not mw
.refreshFiles() and settings().quitOnNoChanges
and not options
.gui
:
490 print 'No outstanding changes'
493 mw
.resize(settings().width
, settings().height
)
495 # The following code doesn't work correctly in some (at least
497 # managers. http://doc.trolltech.com/3.3/geometry.html contains some
498 # information about this issue.
499 # mw.move(settings.readNumEntry('x', 100)[0],
500 # settings.readNumEntry('y', 100)[0])
503 app
.setMainWidget(mw
)
506 # Handle CTRL-C appropriately
507 signal
.signal(signal
.SIGINT
, lambda s
, f
: app
.quit())
509 ret
= app
.exec_loop()
510 settings().writeSettings()